Wired all the dashboard 5.0 charts with the stats service

refs https://github.com/TryGhost/Team/issues/1442

All the data from the charts now come from the dashboard stats service
This commit is contained in:
Simon Backx 2022-03-23 16:38:16 +01:00
parent 101ebadc47
commit 159f56b0d2
17 changed files with 494 additions and 63 deletions

View File

@ -30,6 +30,11 @@ export default class DashboardDashboardV5Component extends Component {
daysOptions = DAYS_OPTIONS;
constructor() {
super(...arguments);
//this.dashboardMocks.updateMockedData({days: 14});
}
get selectedDaysOption() {
return this.daysOptions.find(d => d.value === this.days);
}

View File

@ -15,11 +15,15 @@
<div class="prototype-box">
<h4>Email open rate</h4>
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}
</div>
</div>

View File

@ -1,12 +1,38 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ChartEmailOpenRate extends Component {
@service dashboardStats;
constructor() {
super(...arguments);
this.loadCharts();
}
/**
* Call this method when you need to fetch new data from the server. In this component, it will get called
* when the days parameter changes and on initialisation.
*/
@action
loadCharts() {
// The dashboard stats service will take care or reusing and limiting API-requests between charts
this.dashboardStats.loadNewsletterSubscribers();
this.dashboardStats.loadEmailsSent();
this.dashboardStats.loadEmailOpenRateStats();
}
get dataSubscribers() {
return '9,250';
// @todo: show paid, free, total together
return this.dashboardStats.newsletterSubscribers?.total ?? 0;
}
get dataEmailsSent() {
return '40.3k';
return this.dashboardStats.emailsSent30d ?? 0;
}
get loading() {
return this.dashboardStats.emailOpenRateStats === null;
}
get chartType() {
@ -14,10 +40,14 @@ export default class ChartEmailOpenRate extends Component {
}
get chartData() {
const stats = this.dashboardStats.emailOpenRateStats.filter(stat => stat.email.deliveredCount > 0);
const labels = stats.map(stat => stat.title);
const data = stats.map(stat => stat.email.openedCount / stat.email.deliveredCount * 100);
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June'],
labels,
datasets: [{
data: [65, 59, 80, 81, 56, 55, 40],
data,
fill: false,
backgroundColor: '#14b8ff',
tension: 0.1

View File

@ -1,4 +1,4 @@
<h2>Engagement</h2>
<h2>Engagement ({{this.status}})</h2>
<div class="prototype-counts">
<div class="prototype-box">

View File

@ -1,11 +1,59 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
export default class ChartEngagement extends Component {
@service dashboardStats;
constructor() {
super(...arguments);
this.loadCharts();
}
loadCharts() {
this.dashboardStats.loadLastSeen(this.status);
this.dashboardStats.loadMembersCounts();
}
get status() {
// todo: this should come from a dropdown
// + reload stats after changing this value
return 'total';
}
get loading() {
return this.dashboardStats.memberCounts === null
|| !this.dashboardStats.memberCounts[this.status]
|| this.dashboardStats.membersLastSeen30d === null
|| this.dashboardStats.membersLastSeen7d === null;
}
get data30Days() {
return '67%';
if (this.loading) {
return '- %';
}
const total = this.dashboardStats.memberCounts[this.status];
const part = this.dashboardStats.membersLastSeen30d;
if (total <= 0) {
return '- %';
}
const percentage = Math.round(part / total * 100);
return `${percentage}%`;
}
get data7Days() {
return '31%';
if (this.loading) {
return '- %';
}
const total = this.dashboardStats.memberCounts[this.status];
const part = this.dashboardStats.membersLastSeen7d;
if (total <= 0) {
return '- %';
}
const percentage = Math.round(part / total * 100);
return `${percentage}%`;
}
}

View File

@ -1,6 +1,10 @@
<h4>MRR</h4>
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}

View File

@ -20,6 +20,10 @@ export default class ChartMonthlyRevenue extends Component {
this.dashboardStats.loadMrrStats(this.args.days);
}
get loading() {
return this.dashboardStats.mrrStats === null;
}
get chartType() {
return 'line';
}

View File

@ -1,6 +1,10 @@
<h4>Paid members</h4>
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}

View File

@ -1,21 +1,49 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ChartPaidMembers extends Component {
@service dashboardStats;
constructor() {
super(...arguments);
this.loadCharts();
}
/**
* Call this method when you need to fetch new data from the server. In this component, it will get called
* when the days parameter changes and on initialisation.
*/
@action
loadCharts() {
// The dashboard stats service will take care or reusing and limiting API-requests between charts
this.dashboardStats.loadMemberCountStats(this.args.days);
}
get loading() {
return this.dashboardStats.memberCountStats === null;
}
get chartType() {
return 'bar';
}
get chartData() {
const stats = this.dashboardStats.memberCountStats;
const labels = stats.map(stat => stat.date);
const newData = stats.map(stat => stat.newPaid);
const canceledData = stats.map(stat => -stat.canceledPaid);
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June'],
labels: labels,
datasets: [
{
data: [65, 59, 80, 81, 56, 55, 40],
data: newData,
fill: false,
backgroundColor: '#14b8ff',
tension: 0.1
},{
data: [-65, -59, -80, -81, -56, -55, -40],
data: canceledData,
fill: false,
backgroundColor: '#E16262',
tension: 0.1

View File

@ -1,6 +1,10 @@
<h4>Paid mix</h4>
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
<h4>Paid mix ({{this.mode}})</h4>
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}

View File

@ -1,20 +1,57 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ChartPaidMix extends Component {
@service dashboardStats;
constructor() {
super(...arguments);
this.loadCharts();
}
/**
* Call this method when you need to fetch new data from the server. In this component, it will get called
* when the days parameter changes and on initialisation.
*/
@action
loadCharts() {
// The dashboard stats service will take care or reusing and limiting API-requests between charts
if (this.mode === 'cadence') {
this.dashboardStats.loadPaidMembersByCadence();
} else {
this.dashboardStats.loadPaidMembersByTier();
}
}
get loading() {
if (this.mode === 'cadence') {
return this.dashboardStats.paidMembersByCadence === null;
}
return this.dashboardStats.paidMembersByTier === null;
}
get mode() {
return 'cadence';
}
get chartType() {
return 'pie';
}
get chartData() {
return {
labels: ['Monthly', 'Annual'],
datasets: [{
data: [20, 80],
fill: false,
backgroundColor: ['#14b8ff'],
tension: 0.1
}]
};
if (this.mode === 'cadence') {
return {
labels: ['Monthly', 'Annual'],
datasets: [{
data: [this.dashboardStats.paidMembersByCadence.monthly, this.dashboardStats.paidMembersByCadence.annual],
fill: false,
backgroundColor: ['#14b8ff'],
tension: 0.1
}]
};
}
throw new Error('Not yet supported');
}
get chartOptions() {

View File

@ -1,6 +1,10 @@
<h4>Total members</h4>
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}

View File

@ -19,6 +19,10 @@ export default class ChartTotalMembers extends Component {
// The dashboard stats service will take care or reusing and limiting API-requests between charts
this.dashboardStats.loadMemberCountStats(this.args.days);
}
get loading() {
return this.dashboardStats.memberCountStats === null;
}
get chartType() {
return 'line';

View File

@ -1,6 +1,10 @@
<h4>Total paid</h4>
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{#if this.loading}}
<div class="prototype-placeholder" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}

View File

@ -20,6 +20,10 @@ export default class ChartTotalPaid extends Component {
this.dashboardStats.loadMemberCountStats(this.args.days);
}
get loading() {
return this.dashboardStats.memberCountStats === null;
}
get chartType() {
return 'line';
}

View File

@ -1,6 +1,15 @@
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
*/
/**
* Service that contains fake data to be used by the DashboardStatsService if useMocks is enabled
*/
@ -8,27 +17,63 @@ export default class DashboardMocksService extends Service {
@tracked
enabled = true;
/**
* @type {?MemberCounts} memberCounts
*/
@tracked
memberCounts = null;
/**
* @type {?MemberCountStat[]}
*/
@tracked
memberCountStats = [];
memberCountStats = null;
/**
* @type {?MrrStat[]}
*/
@tracked
mrrStats = [];
mrrStats = null;
/**
* @type {PaidMembersByCadence} Number of members for annual and monthly plans
*/
@tracked
membersLastSeen30d = 123;
paidMembersByCadence = null;
/**
* @type {PaidMembersForTier[]} Number of members for each tier
*/
@tracked
membersLastSeen7d = 51;
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;
@ -41,6 +86,9 @@ export default class DashboardMocksService extends Service {
const startDate = new Date();
startDate.setDate(startDate.getDate() - generateDays + 1);
/**
* @type {MemberCountStat[]}
*/
const stats = [];
let growPeriod = true;
let growCount = 0;
@ -53,11 +101,14 @@ export default class DashboardMocksService extends Service {
const previous = stats.length ? stats[stats.length - 1] : {free: 0, paid: 0, comped: 0};
const paid = index === 0 ? 0 : Math.max(0, previous.paid + Math.round(Math.random() * (growRate - 3)));
stats.push({
date: date.toISOString().split('T')[0],
free: index === 0 ? 0 : Math.max(0, previous.free + Math.floor(Math.random() * (growRate))),
paid: index === 0 ? 0 : Math.max(0, previous.paid + Math.floor(Math.random() * (growRate - 3))),
comped: 0
free: index === 0 ? 0 : Math.max(0, previous.free + Math.round(Math.random() * (growRate))),
paid,
comped: 0,
newPaid: Math.max(paid - previous.paid + 5, 0),
canceledPaid: Math.max(previous.paid - paid, 0) + 5
});
if (growPeriod) {
@ -94,6 +145,48 @@ export default class DashboardMocksService extends Service {
free: stats[stats.length - 1].free + stats[stats.length - 1].comped
};
this.paidMembersByCadence = {
annual: 546,
monthly: 5162
};
this.paidMembersByTier = [
{
tier: {
name: 'Gold tier'
},
members: 124
},
{
tier: {
name: 'Silver tier'
},
members: 459
}
];
this.newsletterSubscribers = {
paid: 156,
free: 8459,
total: 156 + 8459
};
this.emailsSent30d = 123;
this.membersLastSeen7d = Math.round(Math.random() * this.memberCounts.free / 2);
this.membersLastSeen30d = this.membersLastSeen7d + Math.round(Math.random() * this.memberCounts.free / 2);
this.emailOpenRateStats = [
{
id: '23424',
title: 'Test post',
email: {
openedCount: 518,
deliveredCount: 1234
}
}
];
this.mrrStats = stats.map((s) => {
return {
date: s.date,

View File

@ -2,36 +2,121 @@ import Service, {inject as service} from '@ember/service';
import moment from 'moment';
import {tracked} from '@glimmer/tracking';
/**
* @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} newPaid Amount of new paid members
* @property {number} canceledPaid Amount of canceled paid members
*/
/**
* @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
*/
/**
* @todo: THIS ONE IS TEMPORARY
* @typedef EmailOpenRateStat (Will be the same as post model probably)
* @type {Object}
* @property {string} id Post id
* @property {string} title Post title
* @property {?Object} Email model
*/
/**
* @typedef PaidMembersByCadence
* @type {Object}
* @property {number} annual Paid memebrs on annual plan
* @property {number} monthly Paid memebrs on monthly plan
*/
/**
* @typedef PaidMembersForTier
* @type {Object}
* @property {Object} tier Tier object
* @property {number} members Paid members on this tier
*/
export default class DashboardStatsService extends Service {
@service dashboardMocks;
/**
* @type {?MemberCounts} memberCounts
*/
@tracked
memberCounts = null;
/**
* @type {?MemberCountStat[]}
*/
@tracked
memberCountStats = [];
memberCountStats = null;
/**
* @type {?MrrStat[]}
*/
@tracked
mrrStats = [];
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;
loadMembersCounts() {
if (this.dashboardMocks.enabled) {
this.memberCounts = this.dashboardMocks.memberCounts;
this.memberCounts = {...this.dashboardMocks.memberCounts};
return;
}
// Normal implementation
@ -47,7 +132,10 @@ export default class DashboardStatsService extends Service {
*/
loadMemberCountStats(days) {
if (this.dashboardMocks.enabled) {
this.memberCountStats = this.fillMissingDates(this.dashboardMocks.memberCountStats.slice(-days), {paid: 0,free: 0,comped: 0}, days);
if (this.dashboardMocks.memberCountStats === null) {
return null;
}
this.memberCountStats = this.fillMissingDates(this.dashboardMocks.memberCountStats.slice(-days), {paid: 0, free: 0, comped: 0}, days);
return;
}
@ -61,6 +149,9 @@ export default class DashboardStatsService extends Service {
*/
loadMrrStats(days) {
if (this.dashboardMocks.enabled) {
if (this.dashboardMocks.mrrStats === null) {
return null;
}
this.mrrStats = this.fillMissingDates(this.dashboardMocks.mrrStats.slice(-days), {mrr: 0}, days);
return;
}
@ -69,8 +160,15 @@ export default class DashboardStatsService extends Service {
// @todo
}
loadLastSeen() {
/**
* Loads the mrr graphs
* @param {'paid'|'free'|'total'} status filter by status
*/
loadLastSeen(status) {
if (this.dashboardMocks.enabled) {
if (status === 'paid') {
// @todo
}
this.membersLastSeen30d = this.dashboardMocks.membersLastSeen30d;
this.membersLastSeen7d = this.dashboardMocks.membersLastSeen7d;
return;
@ -79,19 +177,74 @@ export default class DashboardStatsService extends Service {
// @todo
}
loadPaidMembersByCadence() {
if (this.dashboardMocks.enabled) {
this.paidMembersByCadence = this.dashboardMocks.paidMembersByCadence;
return;
}
// Normal implementation
// @todo
}
loadPaidMembersByTier() {
if (this.dashboardMocks.enabled) {
this.paidMembersByTier = this.dashboardMocks.paidMembersByTier;
return;
}
// Normal implementation
// @todo
}
loadNewsletterSubscribers() {
if (this.dashboardMocks.enabled) {
this.newsletterSubscribers = this.dashboardMocks.newsletterSubscribers;
return;
}
// Normal implementation
// @todo
}
loadEmailsSent() {
if (this.dashboardMocks.enabled) {
this.emailsSent30d = this.dashboardMocks.emailsSent30d;
return;
}
// Normal implementation
// @todo
}
loadEmailOpenRateStats() {
if (this.dashboardMocks.enabled) {
this.emailOpenRateStats = this.dashboardMocks.emailOpenRateStats;
return;
}
// Normal implementation
// @todo
}
/**
* For now this is only used when reloading all the graphs after changing the mocked data
* @todo: reload only data that we loaded earlier
* @param {number} days Amount of days to load data for (used for member related graphs)
*/
reloadAll(days) {
this.loadMembersCounts();
this.loadMrrStats(days);
this.loadMemberCountStats(days);
this.loadLastSeen();
this.loadLastSeen('paid');
this.loadPaidMembersByCadence();
this.loadPaidMembersByTier();
this.loadNewsletterSubscribers();
this.loadEmailsSent();
this.loadEmailOpenRateStats();
}
/**
* Fill data to match a given amount of days
* @param {MemberCountStat[]|MrrStat[]} data
* @param {MemberCountStat|MrrStat} defaultData
* @param {number} days Amount of days to fill the graph with
*/
fillMissingDates(data, defaultData, days) {
let currentRangeDate = moment().subtract(days, 'days');
@ -115,6 +268,7 @@ export default class DashboardStatsService extends Service {
let dateStr = currentRangeDate.format('YYYY-MM-DD');
const dataOnDate = data.find(d => d.date === dateStr);
lastVal = dataOnDate ? dataOnDate : lastVal;
lastVal.date = dateStr;
output.push(lastVal);
currentRangeDate = currentRangeDate.add(1, 'day');
}