Added dashboard 5.0 stats service and days dropdown

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

- Added very basic state selection at the bottom of dashboard 5.0
- Added a dashboard stats service, who is responsible for fetching and returning stats data
- Added POC for days dropdown with communication and reload between ember components
- Added proper automatic number and plural formatting for member counts
This commit is contained in:
Simon Backx 2022-03-23 09:51:53 +01:00
parent 636a1b3588
commit 98c93b66c8
8 changed files with 314 additions and 19 deletions

View File

@ -5,12 +5,24 @@
</section> </section>
<section class="prototype-section"> <section class="prototype-section">
<article class="gh-dashboard-box"> <div class="prototype-day-selection">
Dropdown: 7d/30d/90d/all time <PowerSelect
</article> @selected={{this.selectedDaysOption}}
@options={{this.daysOptions}}
@searchEnabled={{false}}
@onChange={{this.onDaysChange}}
@triggerComponent="gh-power-select/trigger"
@triggerClass="gh-contentfilter-menu-trigger"
@dropdownClass="gh-contentfilter-menu-dropdown"
@matchTriggerWidth={{false}}
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
<article class="gh-dashboard-box"> <article class="gh-dashboard-box">
<Dashboard::V5::ChartTotalMembers /> <Dashboard::V5::ChartTotalMembers @days={{this.days}} />
</article> </article>
{{#if this.hasPaidTiers}} {{#if this.hasPaidTiers}}
@ -74,6 +86,7 @@
<div class="gh-main-section prototype-panel"> <div class="gh-main-section prototype-panel">
<h4 class="gh-main-section-header small bn">Prototype control panel</h4> <h4 class="gh-main-section-header small bn">Prototype control panel</h4>
<div class="gh-expandable"> <div class="gh-expandable">
<div class="gh-expandable-block"> <div class="gh-expandable-block">
<div class="gh-expandable-header"> <div class="gh-expandable-header">
@ -143,4 +156,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="prototype-states-buttons">
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 0)}}><span>0 days</span></button>
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 2)}}><span>2 days</span></button>
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 7)}}><span>7 days</span></button>
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 14)}}><span>14 days</span></button>
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 30)}}><span>30 days</span></button>
<button class="gh-btn" type="button" {{on "click" (fn this.updateMockedData 365)}}><span>1 year</span></button>
</div>
</div> </div>

View File

@ -1,12 +1,38 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking'; import {tracked} from '@glimmer/tracking';
const DAYS_OPTIONS = [{
name: '7 days',
value: 7
}, {
name: '30 days',
value: 30
}, {
name: '90 days',
value: 90
}, {
name: 'All time',
value: 365 // todo: add support for all time (not important for prototype)
}];
export default class DashboardDashboardV5Component extends Component { export default class DashboardDashboardV5Component extends Component {
@service dashboardStats;
@tracked mockPaidTiers = true; @tracked mockPaidTiers = true;
@tracked mockStripeEnabled = true; @tracked mockStripeEnabled = true;
@tracked mockNewslettersEnabled = true; @tracked mockNewslettersEnabled = true;
@tracked mockMembersEnabled = true; @tracked mockMembersEnabled = true;
@tracked days = 30;
daysOptions = DAYS_OPTIONS;
get selectedDaysOption() {
return this.daysOptions.find(d => d.value === this.days);
}
get hasPaidTiers() { get hasPaidTiers() {
return this.mockPaidTiers; return this.mockPaidTiers;
} }
@ -22,4 +48,68 @@ export default class DashboardDashboardV5Component extends Component {
get areMembersEnabled() { get areMembersEnabled() {
return this.mockMembersEnabled; return this.mockMembersEnabled;
} }
@action
onDaysChange(selected) {
this.days = selected.value;
}
/**
* 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
*/
@action
updateMockedData(days) {
const generateDays = days;
const startDate = new Date();
startDate.setDate(startDate.getDate() - generateDays + 1);
const stats = [];
let growPeriod = true;
let growCount = 0;
let growLength = Math.floor(Math.random() * 14);
for (let index = 0; index < generateDays; index++) {
const date = new Date(startDate.getTime());
date.setDate(date.getDate() + index);
const previous = stats.length ? stats[stats.length - 1] : {free: 0, paid: 0, comped: 0};
stats.push({
date: date.toISOString().split('T')[0],
free: previous.free + (growPeriod ? (index + Math.floor(Math.random() * (previous.free) * 0.01)) : 0),
paid: previous.paid + Math.floor(Math.random() * (previous.free) * 0.005),
comped: previous.comped + Math.floor(Math.random() * 1)
});
if (growPeriod) {
growCount += 1;
if (growCount > growLength) {
growPeriod = false;
growCount = 0;
growLength = Math.floor(Math.random() * 14);
}
} else {
growCount += 1;
if (growCount > growLength) {
growPeriod = true;
growCount = 0;
growLength = Math.floor(Math.random() * 14);
}
}
}
this.dashboardStats.mockedMemberCountStats = stats;
this.dashboardStats.mockedMemberCounts = {
total: stats[stats.length - 1].paid + stats[stats.length - 1].free + stats[stats.length - 1].comped,
paid: stats[stats.length - 1].paid,
free: stats[stats.length - 1].free + stats[stats.length - 1].comped
};
// Force update all data
this.dashboardStats.loadMembersCounts();
this.dashboardStats.loadMrrStats(this.days);
this.dashboardStats.loadMemberCountStats(this.days);
this.dashboardStats.loadLastSeen();
}
} }

View File

@ -1,16 +1,16 @@
<div class="prototype-counts"> <div class="prototype-counts">
<div class="prototype-box"> <div class="prototype-box">
<div class="number">{{this.dataTotalMembers}}</div> <div class="number">{{format-number this.totalMembers}}</div>
<div>Total members</div> <div>{{gh-pluralize this.totalMembers "Total member" without-count=true}}</div>
</div> </div>
<div class="prototype-box"> <div class="prototype-box">
<div class="number">{{this.dataPaidMembers}}</div> <div class="number">{{format-number this.paidMembers}}</div>
<div>Paid members</div> <div>{{gh-pluralize this.paidMembers "Paid member" without-count=true}}</div>
</div> </div>
<div class="prototype-box"> <div class="prototype-box">
<div class="number">{{this.dataFreeMembers}}</div> <div class="number">{{format-number this.freeMembers}}</div>
<div>Free members</div> <div>{{gh-pluralize this.freeMembers "Free member" without-count=true}}</div>
</div> </div>
</div> </div>

View File

@ -1,15 +1,27 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
export default class ChartMembersCounts extends Component { export default class ChartMembersCounts extends Component {
get dataTotalMembers() { @service dashboardStats;
return '10,000';
constructor() {
super(...arguments);
this.loadCharts();
} }
get dataPaidMembers() { loadCharts() {
return '1,500'; this.dashboardStats.loadMembersCounts();
} }
get dataFreeMembers() { get totalMembers() {
return '8,500'; return this.dashboardStats.memberCounts?.total ?? 0;
}
get paidMembers() {
return this.dashboardStats.memberCounts?.paid ?? 0;
}
get freeMembers() {
return this.dashboardStats.memberCounts?.free ?? 0;
} }
} }

View File

@ -1,5 +1,5 @@
<h4>Total members</h4> <h4>Total members</h4>
<EmberChart <EmberChart {{did-update this.loadCharts @days}}
@type={{this.chartType}} @type={{this.chartType}}
@data={{this.chartData}} @data={{this.chartData}}
@options={{this.chartOptions}} @options={{this.chartOptions}}

View File

@ -1,15 +1,39 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import moment from 'moment';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ChartTotalMembers extends Component { export default class ChartTotalMembers 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 chartType() { get chartType() {
return 'line'; return 'line';
} }
get chartData() { get chartData() {
const stats = this.fillCountDates(this.dashboardStats.memberCountStats, this.args.days);
const labels = Object.keys(stats);
const data = Object.values(stats).map(stat => stat.total);
return { return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June'], labels: labels,
datasets: [{ datasets: [{
data: [65, 59, 80, 81, 56, 55, 40], data: data,
fill: false, fill: false,
borderColor: '#14b8ff', borderColor: '#14b8ff',
tension: 0.1 tension: 0.1
@ -28,4 +52,43 @@ export default class ChartTotalMembers extends Component {
get chartHeight() { get chartHeight() {
return 300; return 300;
} }
/**
* This method is borrowed from the members stats service and would need an update
*/
fillCountDates(data = {}, days) {
let currentRangeDate = moment().subtract(days, '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 = {
paid: initialDateInRangeVal ? initialDateInRangeVal.paid : 0,
free: initialDateInRangeVal ? initialDateInRangeVal.free : 0,
comped: initialDateInRangeVal ? initialDateInRangeVal.comped : 0,
total: initialDateInRangeVal ? (initialDateInRangeVal.paid + initialDateInRangeVal.free + initialDateInRangeVal.comped) : 0
};
while (currentRangeDate.isBefore(endDate)) {
let dateStr = currentRangeDate.format('YYYY-MM-DD');
const dataOnDate = data.find(d => d.date === dateStr);
output[dateStr] = dataOnDate ? {
paid: dataOnDate.paid,
free: dataOnDate.free,
comped: dataOnDate.comped,
total: dataOnDate.paid + dataOnDate.free + dataOnDate.comped
} : lastVal;
lastVal = output[dateStr];
currentRangeDate = currentRangeDate.add(1, 'day');
}
return output;
}
} }

View File

@ -0,0 +1,96 @@
import Service from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class DashboardStatsService extends Service {
@tracked
useMocks = true;
@tracked
memberCounts = null;
@tracked
memberCountStats = [];
@tracked
mrrStats = [];
@tracked
membersLastSeen30d = null;
@tracked
membersLastSeen7d = null;
@tracked
newsletterSubscribers = null;
@tracked
emailsSent30d = null;
@tracked
emailOpenRateStats = null;
loadMembersCounts() {
if (this.useMocks) {
this.memberCounts = this.mockedMemberCounts;
return;
}
// Normal implementation
// @todo
}
/**
* Loads the members graphs
* - total paid
* - total members
* for each day in the last {{days}} days
* @param {number} days The number of days to fetch data for
*/
loadMemberCountStats(days) {
if (this.useMocks) {
this.memberCountStats = this.mockedMemberCountStats.slice(-days);
return;
}
// Normal implementation
// @todo
}
/**
* Loads the mrr graphs
* @param {number} days The number of days to fetch data for
*/
loadMrrStats(days) {
if (this.useMocks) {
this.mmrStats = this.mockedMrrStats.slice(-days);
return;
}
// Normal implementation
// @todo
}
loadLastSeen() {
if (this.useMocks) {
this.membersLastSeen30d = 620;
this.membersLastSeen7d = 320;
return;
}
// Normal implementation
// @todo
}
// Mocked data (move this to a mocking service?)
@tracked
mockedMemberCountStats = [];
@tracked
mockedMrrStats = [];
@tracked
mockedMemberCounts = {
total: 0,
paid: 0,
free: 0
};
}

View File

@ -1081,6 +1081,18 @@ a.gh-dashboard-container {
margin-top: 50vh; margin-top: 50vh;
} }
.prototype-states-buttons {
margin-top: 15px;
display: flex;
flex-direction: row;
gap: 10px;
}
.prototype-day-selection {
display: flex;
justify-content: flex-end;
}
.prototype-counts { .prototype-counts {
display: flex; display: flex;
flex-direction: row; flex-direction: row;