From 6a1edcededb94814f53b8fcc1746dde54d954b60 Mon Sep 17 00:00:00 2001 From: Rishabh Garg Date: Tue, 17 Dec 2019 15:59:26 +0530 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Added=20members=20growth=20chart=20(#1?= =?UTF-8?q?424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue Adds new members growth chart in members list screen to highlight growth of members over different date ranges --- .../admin/app/components/gh-members-chart.js | 247 ++++++++++++++++++ ghost/admin/app/styles/app-dark.css | 8 + ghost/admin/app/styles/layouts/members.css | 78 ++++++ .../templates/components/gh-members-chart.hbs | 55 ++++ ghost/admin/app/templates/members.hbs | 7 + 5 files changed, 395 insertions(+) create mode 100644 ghost/admin/app/components/gh-members-chart.js create mode 100644 ghost/admin/app/templates/components/gh-members-chart.hbs diff --git a/ghost/admin/app/components/gh-members-chart.js b/ghost/admin/app/components/gh-members-chart.js new file mode 100644 index 0000000000..bac1088365 --- /dev/null +++ b/ghost/admin/app/components/gh-members-chart.js @@ -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 + } + }] + } + } + }; + } +}); diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index 19704ebe29..0f6124da3b 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -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%)); +} \ No newline at end of file diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 551c0fdd93..316ef2775c 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -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; diff --git a/ghost/admin/app/templates/components/gh-members-chart.hbs b/ghost/admin/app/templates/components/gh-members-chart.hbs new file mode 100644 index 0000000000..8ad44e216b --- /dev/null +++ b/ghost/admin/app/templates/components/gh-members-chart.hbs @@ -0,0 +1,55 @@ +
+ + {{!-- Chart title/filter graph --}} +
+
+

Total members

+
+
+ {{#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}} +
+
+
+
+ {{ember-chart type='LineWithLine' options=subData.chartData.options data=subData.chartData.data height=300}} +
+
+ {{subData.startDateLabel}} + Today +
+
+ + {{!-- Summary --}} +
+
+

Total Members

+
{{subData.totalSubs}}
+
+
+ {{#if (eq range "all-time")}} +

All time signups

+ {{else}} +

Signed up in the last {{range}} days

+ {{/if}} +
{{subData.totalSubsInRange}}
+
+
+

Signed up today

+
{{subData.totalSubsToday}}
+
+
+
\ No newline at end of file diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index 9d6c480b0a..684da025f1 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -44,6 +44,13 @@
+ {{#if filteredMembers}} + {{#unless this.searchText}} +
+ {{gh-members-chart members=members}} +
+ {{/unless}} + {{/if}}
    {{#if this.filteredMembers}}