mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 20:03:12 +03:00
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:
parent
636a1b3588
commit
98c93b66c8
@ -5,12 +5,24 @@
|
||||
</section>
|
||||
|
||||
<section class="prototype-section">
|
||||
<article class="gh-dashboard-box">
|
||||
Dropdown: 7d/30d/90d/all time
|
||||
</article>
|
||||
<div class="prototype-day-selection">
|
||||
<PowerSelect
|
||||
@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">
|
||||
<Dashboard::V5::ChartTotalMembers />
|
||||
<Dashboard::V5::ChartTotalMembers @days={{this.days}} />
|
||||
</article>
|
||||
|
||||
{{#if this.hasPaidTiers}}
|
||||
@ -74,6 +86,7 @@
|
||||
|
||||
<div class="gh-main-section prototype-panel">
|
||||
<h4 class="gh-main-section-header small bn">Prototype control panel</h4>
|
||||
|
||||
<div class="gh-expandable">
|
||||
<div class="gh-expandable-block">
|
||||
<div class="gh-expandable-header">
|
||||
@ -143,4 +156,13 @@
|
||||
</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>
|
@ -1,11 +1,37 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
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 {
|
||||
@service dashboardStats;
|
||||
|
||||
@tracked mockPaidTiers = true;
|
||||
@tracked mockStripeEnabled = true;
|
||||
@tracked mockNewslettersEnabled = true;
|
||||
@tracked mockMembersEnabled = true;
|
||||
|
||||
@tracked days = 30;
|
||||
|
||||
daysOptions = DAYS_OPTIONS;
|
||||
|
||||
get selectedDaysOption() {
|
||||
return this.daysOptions.find(d => d.value === this.days);
|
||||
}
|
||||
|
||||
get hasPaidTiers() {
|
||||
return this.mockPaidTiers;
|
||||
@ -22,4 +48,68 @@ export default class DashboardDashboardV5Component extends Component {
|
||||
get areMembersEnabled() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,16 @@
|
||||
<div class="prototype-counts">
|
||||
<div class="prototype-box">
|
||||
<div class="number">{{this.dataTotalMembers}}</div>
|
||||
<div>Total members</div>
|
||||
<div class="number">{{format-number this.totalMembers}}</div>
|
||||
<div>{{gh-pluralize this.totalMembers "Total member" without-count=true}}</div>
|
||||
</div>
|
||||
|
||||
<div class="prototype-box">
|
||||
<div class="number">{{this.dataPaidMembers}}</div>
|
||||
<div>Paid members</div>
|
||||
<div class="number">{{format-number this.paidMembers}}</div>
|
||||
<div>{{gh-pluralize this.paidMembers "Paid member" without-count=true}}</div>
|
||||
</div>
|
||||
|
||||
<div class="prototype-box">
|
||||
<div class="number">{{this.dataFreeMembers}}</div>
|
||||
<div>Free members</div>
|
||||
<div class="number">{{format-number this.freeMembers}}</div>
|
||||
<div>{{gh-pluralize this.freeMembers "Free member" without-count=true}}</div>
|
||||
</div>
|
||||
</div>
|
@ -1,15 +1,27 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ChartMembersCounts extends Component {
|
||||
get dataTotalMembers() {
|
||||
return '10,000';
|
||||
@service dashboardStats;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadCharts();
|
||||
}
|
||||
|
||||
get dataPaidMembers() {
|
||||
return '1,500';
|
||||
loadCharts() {
|
||||
this.dashboardStats.loadMembersCounts();
|
||||
}
|
||||
|
||||
get dataFreeMembers() {
|
||||
return '8,500';
|
||||
get totalMembers() {
|
||||
return this.dashboardStats.memberCounts?.total ?? 0;
|
||||
}
|
||||
|
||||
get paidMembers() {
|
||||
return this.dashboardStats.memberCounts?.paid ?? 0;
|
||||
}
|
||||
|
||||
get freeMembers() {
|
||||
return this.dashboardStats.memberCounts?.free ?? 0;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<h4>Total members</h4>
|
||||
<EmberChart
|
||||
<EmberChart {{did-update this.loadCharts @days}}
|
||||
@type={{this.chartType}}
|
||||
@data={{this.chartData}}
|
||||
@options={{this.chartOptions}}
|
||||
|
@ -1,15 +1,39 @@
|
||||
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 {
|
||||
@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() {
|
||||
return 'line';
|
||||
}
|
||||
|
||||
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 {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June'],
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: [65, 59, 80, 81, 56, 55, 40],
|
||||
data: data,
|
||||
fill: false,
|
||||
borderColor: '#14b8ff',
|
||||
tension: 0.1
|
||||
@ -28,4 +52,43 @@ export default class ChartTotalMembers extends Component {
|
||||
get chartHeight() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
96
ghost/admin/app/services/dashboard-stats.js
Normal file
96
ghost/admin/app/services/dashboard-stats.js
Normal 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
|
||||
};
|
||||
}
|
@ -1081,6 +1081,18 @@ a.gh-dashboard-container {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
Loading…
Reference in New Issue
Block a user