mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
✨Added members growth chart (#1424)
no issue Adds new members growth chart in members list screen to highlight growth of members over different date ranges
This commit is contained in:
parent
397a1b52f2
commit
6a1edceded
247
ghost/admin/app/components/gh-members-chart.js
Normal file
247
ghost/admin/app/components/gh-members-chart.js
Normal file
@ -0,0 +1,247 @@
|
||||
/* global Chart */
|
||||
import Component from '@ember/component';
|
||||
import moment from 'moment';
|
||||
import {computed, get} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default Component.extend({
|
||||
feature: service(),
|
||||
members: null,
|
||||
range: '30',
|
||||
selectedRange: computed('range', function () {
|
||||
const availableRange = this.get('availableRange');
|
||||
return availableRange.findBy('days', this.get('range'));
|
||||
}),
|
||||
availableRange: computed(function () {
|
||||
return [
|
||||
{
|
||||
name: '30 days',
|
||||
days: '30'
|
||||
},
|
||||
{
|
||||
name: '90 days',
|
||||
days: '90'
|
||||
},
|
||||
{
|
||||
name: '365 days',
|
||||
days: '365'
|
||||
},
|
||||
{
|
||||
name: 'All time',
|
||||
days: 'all-time'
|
||||
}
|
||||
];
|
||||
}),
|
||||
|
||||
subData: computed('members.@each', 'range', 'feature.nightShift', function () {
|
||||
let isNightShiftEnabled = this.feature.nightShift;
|
||||
let {members, range} = this;
|
||||
let rangeInDays, rangeStartDate, rangeEndDate;
|
||||
if (range === 'last-year') {
|
||||
rangeStartDate = moment().startOf('year').subtract(1, 'year');
|
||||
rangeEndDate = moment().endOf('year').subtract(1, 'year').subtract(1, 'day');
|
||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
||||
} else if (range === 'all-time') {
|
||||
let firstMemberCreatedDate = members.length ? members.lastObject.get('createdAtUTC') : moment().subtract(365, 'days');
|
||||
rangeStartDate = moment(firstMemberCreatedDate);
|
||||
rangeEndDate = moment();
|
||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
||||
if (rangeInDays < 5) {
|
||||
rangeStartDate = moment().subtract(6, 'days');
|
||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
||||
}
|
||||
let step = this.getTicksForRange(rangeInDays);
|
||||
rangeInDays = Math.ceil(rangeInDays / step) * step;
|
||||
rangeStartDate = moment().subtract(rangeInDays, 'days');
|
||||
} else {
|
||||
rangeInDays = parseInt(range);
|
||||
rangeStartDate = moment().subtract((rangeInDays), 'days');
|
||||
rangeEndDate = moment();
|
||||
}
|
||||
let totalSubs = members.length || 0;
|
||||
let totalSubsLastMonth = members.filter((member) => {
|
||||
let isValid = moment(member.createdAtUTC).isSameOrAfter(rangeStartDate, 'day');
|
||||
return isValid;
|
||||
}).length;
|
||||
|
||||
let totalSubsToday = members.filter((member) => {
|
||||
let isValid = moment(member.createdAtUTC).isSame(moment(), 'day');
|
||||
return isValid;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
startDateLabel: moment(rangeStartDate).format('MMM DD, YYYY'),
|
||||
chartData: this.getChartData(members, moment(rangeStartDate), moment(rangeEndDate), isNightShiftEnabled),
|
||||
totalSubs: totalSubs,
|
||||
totalSubsToday: totalSubsToday,
|
||||
totalSubsInRange: totalSubsLastMonth
|
||||
};
|
||||
}),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.setChartJSDefaults();
|
||||
},
|
||||
|
||||
actions: {
|
||||
changeDateRange(range) {
|
||||
this.set('range', get(range, 'days'));
|
||||
}
|
||||
},
|
||||
|
||||
setChartJSDefaults() {
|
||||
let isNightShiftEnabled = this.feature.nightShift;
|
||||
Chart.defaults.LineWithLine = Chart.defaults.line;
|
||||
Chart.controllers.LineWithLine = Chart.controllers.line.extend({
|
||||
draw: function (ease) {
|
||||
Chart.controllers.line.prototype.draw.call(this, ease);
|
||||
|
||||
if (this.chart.tooltip._active && this.chart.tooltip._active.length) {
|
||||
var activePoint = this.chart.tooltip._active[0],
|
||||
ctx = this.chart.ctx,
|
||||
x = activePoint.tooltipPosition().x,
|
||||
topY = this.chart.scales['y-axis-0'].top,
|
||||
bottomY = this.chart.scales['y-axis-0'].bottom;
|
||||
|
||||
// draw line
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, topY);
|
||||
ctx.lineTo(x, bottomY);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = (isNightShiftEnabled ? 'rgba(62, 176, 239, 0.65)' : 'rgba(62, 176, 239, 0.8)');
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getTicksForRange(rangeInDays) {
|
||||
if (rangeInDays <= 30) {
|
||||
return 6;
|
||||
} else if (rangeInDays <= 90) {
|
||||
return 18;
|
||||
} else {
|
||||
return 24;
|
||||
}
|
||||
},
|
||||
|
||||
getChartData(members, startDate, endDate, isNightShiftEnabled) {
|
||||
this.setChartJSDefaults();
|
||||
let dateFormat = 'MMM DD, YYYY';
|
||||
let monthData = [];
|
||||
let dateLabel = [];
|
||||
let rangeInDays = endDate.diff(startDate, 'days');
|
||||
for (var m = moment(startDate); m.isSameOrBefore(endDate, 'day'); m.add(1, 'days')) {
|
||||
dateLabel.push(m.format(dateFormat));
|
||||
let membersTillDate = members.filter((member) => {
|
||||
let isValid = moment(member.createdAtUTC).isSameOrBefore(m, 'day');
|
||||
return isValid;
|
||||
}).length;
|
||||
monthData.push(membersTillDate);
|
||||
}
|
||||
let maxTicksAllowed = this.getTicksForRange(rangeInDays);
|
||||
return {
|
||||
data: {
|
||||
labels: dateLabel,
|
||||
datasets: [{
|
||||
label: 'Total members',
|
||||
cubicInterpolationMode: 'monotone',
|
||||
data: monthData,
|
||||
fill: false,
|
||||
backgroundColor: 'rgba(62,176,239,.9)',
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
borderColor: 'rgba(62,176,239,.9)',
|
||||
borderJoinStyle: 'round'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 5, // Needed otherwise the top dot is cut
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
left: 10
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
displayColors: false,
|
||||
backgroundColor: '#343f44',
|
||||
xPadding: 7,
|
||||
yPadding: 7,
|
||||
cornerRadius: 5,
|
||||
caretSize: 7,
|
||||
caretPadding: 5,
|
||||
bodyFontSize: 13,
|
||||
titleFontStyle: 'normal',
|
||||
titleFontColor: 'rgba(255, 255, 255, 0.7)',
|
||||
titleMarginBottom: 4
|
||||
},
|
||||
hover: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
animationDuration: 120
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
labelString: 'Date',
|
||||
gridLines: {
|
||||
drawTicks: false,
|
||||
color: (isNightShiftEnabled ? '#333F44' : '#E5EFF5'),
|
||||
zeroLineColor: (isNightShiftEnabled ? '#333F44' : '#E5EFF5')
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
padding: 6,
|
||||
autoSkip: false,
|
||||
maxTicksLimit: 10,
|
||||
callback: function (value, index, values) {
|
||||
let step = (values.length - 1) / (maxTicksAllowed);
|
||||
let steps = [];
|
||||
for (let i = 0; i < maxTicksAllowed; i++) {
|
||||
steps.push(Math.round(i * step));
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return value;
|
||||
}
|
||||
if (index === (values.length - 1)) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
if (steps.includes(index)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
gridLines: {
|
||||
drawTicks: false,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
beginAtZero: true
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
@ -499,3 +499,11 @@ input:focus,
|
||||
.apps-grid-cell:hover {
|
||||
background: color-mod(var(--lightgrey) l(-9%));
|
||||
}
|
||||
|
||||
/* Members */
|
||||
.gh-members-chart-header {
|
||||
background: color-mod(var(--lightgrey) l(-9%));
|
||||
}
|
||||
.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger {
|
||||
box-shadow: 0 0 0 1px color-mod(var(--darkgrey) l(-27%) blackness(+15%) alpha(50%));
|
||||
}
|
@ -91,12 +91,90 @@ p.gh-members-list-email {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.gh-members-chart-header {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--whitegrey-l2);
|
||||
border-bottom: var(--lightgrey) 1px solid;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.gh-members-chart-header .gh-contentfilter {
|
||||
margin: 0 0 0 20px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.05);
|
||||
height: 16px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.gh-members-chart-dropdown {
|
||||
margin-left: -103px;
|
||||
}
|
||||
|
||||
.gh-members-chart-container {
|
||||
height: 186px;
|
||||
margin: 0;
|
||||
padding: 0 7px 4px;
|
||||
}
|
||||
|
||||
.gh-members-chart-summary {
|
||||
flex-basis: 24%;
|
||||
min-width: 244px;
|
||||
}
|
||||
|
||||
.gh-members-chart-summary-data {
|
||||
font-size: 3.6rem;
|
||||
color: var(--darkgrey);
|
||||
line-height: 4.0rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.gh-members-chart-summary-data {
|
||||
font-size: 2.8rem;
|
||||
line-height: 2.8rem;
|
||||
}
|
||||
|
||||
.gh-members-chart-container {
|
||||
height: 166px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.members-list .gh-list-header, .gh-list-hidecell-m {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.gh-members-chart-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gh-members-chart-box {
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 440px) and (max-width: 1000px) {
|
||||
.gh-members-chart-summary {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.gh-members-chart-summary div {
|
||||
flex-basis: 33%;
|
||||
border-bottom: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.gh-members-chart-summary > div:nth-of-type(1),
|
||||
.gh-members-chart-summary > div:nth-of-type(2) {
|
||||
border-right: 1px solid var(--whitegrey);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.members-list .gh-list-header, .gh-list-hidecell-m {
|
||||
display: none;
|
||||
|
55
ghost/admin/app/templates/components/gh-members-chart.hbs
Normal file
55
ghost/admin/app/templates/components/gh-members-chart.hbs
Normal file
@ -0,0 +1,55 @@
|
||||
<div class="flex justify-between mb6 items-stretch gh-members-chart-wrapper">
|
||||
|
||||
{{!-- Chart title/filter graph --}}
|
||||
<div class="flex-auto bg-white br3 shadow-1 bg-grouped-table mr6 gh-members-chart-box">
|
||||
<div class="flex justify-between items-center gh-members-chart-header">
|
||||
<h2 class="f-small ttu midgrey fw5 mb0">Total members</h2>
|
||||
<div class="view-actions">
|
||||
<div class="gh-contentfilter">
|
||||
{{#power-select
|
||||
selected=selectedRange
|
||||
options=availableRange
|
||||
searchEnabled=false
|
||||
onchange=(action "changeDateRange")
|
||||
tagName="div"
|
||||
classNames="gh-contentfilter-menu gh-contentfilter-type"
|
||||
triggerClass="gh-contentfilter-menu-trigger"
|
||||
dropdownClass="gh-contentfilter-menu-dropdown gh-members-chart-dropdown"
|
||||
matchTriggerWidth=false
|
||||
data-test-type-select=true
|
||||
as |range|
|
||||
}}
|
||||
{{range.name}}
|
||||
{{/power-select}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-members-chart-container">
|
||||
{{ember-chart type='LineWithLine' options=subData.chartData.options data=subData.chartData.data height=300}}
|
||||
</div>
|
||||
<div class="flex justify-between pa4 pt0 pb2 nt1">
|
||||
<span class="f8 midlightgrey">{{subData.startDateLabel}}</span>
|
||||
<span class="f8 midlightgrey">Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Summary --}}
|
||||
<div class="flex flex-column justify-between gh-members-chart-summary bg-white br3 shadow-1 bg-grouped-table">
|
||||
<div class="flex-auto flex flex-column justify-center items-start pa4 bb b--whitegrey">
|
||||
<h3 class="f-small ttu midgrey fw5">Total Members</h3>
|
||||
<div class="gh-members-chart-summary-data">{{subData.totalSubs}}</div>
|
||||
</div>
|
||||
<div class="flex-auto flex flex-column justify-center items-start pa4 bb b--whitegrey">
|
||||
{{#if (eq range "all-time")}}
|
||||
<h3 class="f-small ttu midgrey fw5">All time signups</h3>
|
||||
{{else}}
|
||||
<h3 class="f-small ttu midgrey fw5">Signed up in the last {{range}} days</h3>
|
||||
{{/if}}
|
||||
<div class="gh-members-chart-summary-data">{{subData.totalSubsInRange}}</div>
|
||||
</div>
|
||||
<div class="flex-auto flex flex-column justify-center items-start pa4">
|
||||
<h3 class="f-small ttu midgrey fw5">Signed up today</h3>
|
||||
<div class="gh-members-chart-summary-data">{{subData.totalSubsToday}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -44,6 +44,13 @@
|
||||
</GhCanvasHeader>
|
||||
|
||||
<section class="view-container">
|
||||
{{#if filteredMembers}}
|
||||
{{#unless this.searchText}}
|
||||
<section>
|
||||
{{gh-members-chart members=members}}
|
||||
</section>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<section class="content-list">
|
||||
<ol class="members-list gh-list {{unless this.filteredMembers "no-posts"}}">
|
||||
{{#if this.filteredMembers}}
|
||||
|
Loading…
Reference in New Issue
Block a user