Dashboard consolidated graph (#2323)

* Basic work to combine the three line graphs for the new dashboard

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

- combined three line graphs into one main one at the top
- still working on this, so some code is a little rough

* Tidying up a few bits of consolidated graph in new dashboard

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

* Updated chart anchor component for removed member counts

no issue

* Updated chart paid members to not reload on days change

no refs

* Moved did-insert to top element in chart-anchor

* Fixed chart anchor to use filled member data

* Replaced chart anchor divs with buttons

* Tweaking up the paid graphs below anchor to improve visuals

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

* Fixed missing type attributes on buttons in chart anchor

* Updated MMR to MRR for the new consolidated graph

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

* Added real MRR to chart anchor

* Added open rate percentage data to chart email

Co-authored-by: Simon Backx <simon@ghost.org>
This commit is contained in:
James Morris 2022-04-01 14:53:55 +01:00 committed by GitHub
parent da972bfe58
commit 8fe18d7e30
13 changed files with 412 additions and 72 deletions

View File

@ -1,12 +1,11 @@
<section {{did-insert this.onInsert}}> <section {{did-insert this.onInsert}}>
{{#if this.isLoading }} {{#if this.isLoading }}
<GhLoadingSpinner /> <GhLoadingSpinner />
{{else}} {{else}}
{{#if this.areMembersEnabled}} {{#if this.areMembersEnabled}}
<section class="gh-dashboard5-section gh-dashboard5-overview"> <section class="gh-dashboard5-section gh-dashboard5-anchor">
<article class="gh-dashboard5-box"> <article class="gh-dashboard5-box">
<Dashboard::V5::ChartTotalMembers @days={{this.days}} /> <Dashboard::V5::ChartAnchor @days={{this.days}} />
</article> </article>
<div class="prototype-selection"> <div class="prototype-selection">
<PowerSelect <PowerSelect
@ -29,14 +28,6 @@
<div class="gh-dashboard5-paid"> <div class="gh-dashboard5-paid">
<section class="gh-dashboard5-section"> <section class="gh-dashboard5-section">
<div class="gh-dashboard5-growth"> <div class="gh-dashboard5-growth">
<article class="gh-dashboard5-box">
<Dashboard::V5::ChartMonthlyRevenue @days={{this.days}} />
</article>
<article class="gh-dashboard5-box">
<Dashboard::V5::ChartTotalPaid @days={{this.days}} />
</article>
<article class="gh-dashboard5-box"> <article class="gh-dashboard5-box">
<Dashboard::V5::ChartPaidMembers @days={{this.days}}/> <Dashboard::V5::ChartPaidMembers @days={{this.days}}/>
</article> </article>

View File

@ -0,0 +1,39 @@
<div class="gh-dashboard5-stats" {{did-insert this.loadCharts}}>
<button type="button" {{on "click" (fn this.changeChartDisplay "total")}} class={{if this.chartShowingTotal 'is-selected'}}>
<div class="gh-dashboard5-number">
{{format-number this.totalMembers}}
{{#if this.hasTrends}}
<Dashboard::v5::parts::ChartPercentage @percentage={{this.totalMembersTrend}}/>
{{/if}}
</div>
<small class="gh-dashboard5-info">{{gh-pluralize this.totalMembers "Total member" without-count=true}}</small>
</button>
<button type="button" {{on "click" (fn this.changeChartDisplay "paid")}} class={{if this.chartShowingPaid 'is-selected'}}>
<div class="gh-dashboard5-number">
{{format-number this.paidMembers}}
{{#if this.hasTrends}}
<Dashboard::v5::parts::ChartPercentage @percentage={{this.paidMembersTrend}}/>
{{/if}}
</div>
<small class="gh-dashboard5-info">{{gh-pluralize this.paidMembers "Paid member" without-count=true}}</small>
</button>
<button type="button" {{on "click" (fn this.changeChartDisplay "monthly")}} class={{if this.chartShowingMonthly 'is-selected'}}>
<div class="gh-dashboard5-number">
${{gh-price-amount this.currentMRR}}
{{#if this.hasTrends}}
<Dashboard::v5::parts::ChartPercentage @percentage={{this.mrrTrend}}/>
{{/if}}
</div>
<small class="gh-dashboard5-info">Monthly revenue (MRR)</small>
</button>
</div>
<div class="gh-dashboard5-chart">
{{#if this.loading}}
<div class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}} />
{{/if}}
</div>

View File

@ -0,0 +1,198 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class ChartAnchor extends Component {
@service dashboardStats;
@tracked chartDisplay = 'total';
@action
loadCharts() {
this.dashboardStats.loadMemberCountStats();
this.dashboardStats.loadMrrStats();
}
@action
changeChartDisplay(type) {
this.chartDisplay = type;
}
get chartShowingTotal() {
return (this.chartDisplay === 'total');
}
get chartShowingPaid() {
return (this.chartDisplay === 'paid');
}
get chartShowingMonthly() {
return (this.chartDisplay === 'monthly');
}
get loading() {
if (this.chartDisplay === 'total') {
return this.dashboardStats.memberCountStats === null;
} else if (this.chartDisplay === 'paid') {
return this.dashboardStats.memberCountStats === null;
} else if (this.chartDisplay === 'monthly') {
return this.dashboardStats.mrrStats === null;
}
return true;
}
get totalMembers() {
return this.dashboardStats.memberCounts?.total ?? 0;
}
get paidMembers() {
return this.dashboardStats.memberCounts?.paid ?? 0;
}
get freeMembers() {
return this.dashboardStats.memberCounts?.free ?? 0;
}
get currentMRR() {
return this.dashboardStats.currentMRR ?? 0;
}
get hasTrends() {
return this.dashboardStats.memberCounts !== null && this.dashboardStats.memberCountsTrend !== null;
}
get totalMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.total, this.dashboardStats.memberCounts.total);
}
get paidMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.paid, this.dashboardStats.memberCounts.paid);
}
get freeMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.free, this.dashboardStats.memberCounts.free);
}
get mrrTrend() {
return this.calculatePercentage(this.dashboardStats.currentMRRTrend, this.dashboardStats.currentMRR);
}
get chartType() {
return 'line';
}
get chartData() {
let stats = [];
let labels = [];
let data = [];
if (this.chartDisplay === 'total') {
stats = this.dashboardStats.filledMemberCountStats;
labels = stats.map(stat => stat.date);
data = stats.map(stat => stat.paid + stat.free + stat.comped);
}
if (this.chartDisplay === 'paid') {
stats = this.dashboardStats.filledMemberCountStats;
labels = stats.map(stat => stat.date);
data = stats.map(stat => stat.paid);
}
if (this.chartDisplay === 'monthly') {
stats = this.dashboardStats.filledMrrStats;
labels = stats.map(stat => stat.date);
data = stats.map(stat => stat.mrr);
}
return {
labels: labels,
datasets: [{
data: data,
tension: 0.1,
cubicInterpolationMode: 'monotone',
fill: true,
fillColor: '#F5FBFF',
backgroundColor: '#F5FBFF',
pointRadius: 0,
pointHitRadius: 10,
borderColor: '#14b8ff',
borderJoinStyle: 'miter',
maxBarThickness: 20,
minBarLength: 2
}]
};
}
get chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
title: {
display: false
},
legend: {
display: false
},
layout: {
padding: {
top: 20
}
},
scales: {
yAxes: [{
gridLines: {
drawTicks: false,
display: false,
drawBorder: false
},
ticks: {
display: false,
maxTicksLimit: 5,
fontColor: '#7C8B9A',
padding: 8,
precision: 0
}
}],
xAxes: [{
gridLines: {
drawTicks: false,
display: false,
drawBorder: false
},
ticks: {
display: false,
maxTicksLimit: 5,
autoSkip: true,
maxRotation: 0,
minRotation: 0
},
type: 'time',
time: {
displayFormats: {
millisecond: 'MMM DD',
second: 'MMM DD',
minute: 'MMM DD',
hour: 'MMM DD',
day: 'MMM DD',
week: 'MMM DD',
month: 'MMM DD',
quarter: 'MMM DD',
year: 'MMM DD'
}
}
}]
}
};
}
calculatePercentage(from, to) {
if (from === 0) {
if (to > 0) {
return 100;
}
return 0;
}
return Math.round((to - from) / from * 100);
}
}

View File

@ -1,13 +1,9 @@
<h3 {{did-insert this.loadCharts}}>Email</h3> <h3 {{did-insert this.loadCharts}}>Email</h3>
<div class="gh-dashboard5-insert"> <div class="gh-dashboard5-insert">
<div class="gh-dashboard5-box"> <div class="gh-dashboard5-box">
{{!-- <h4 class="gh-dashboard5-metric">Newsletter subscribers</h4> --}} <div class="gh-dashboard5-number is-solo">
{{!-- <div class="gh-dashboard5-number is-small">{{format-number this.dataSubscribers.total}}</div> --}} {{format-number this.dataSubscribers.total}}<small><span class="gh-dashboard5-slash">/</span>{{format-number this.dataSubscribers.paid}} paid</small>
{{!-- <div class="gh-dashboard5-number is-small">{{format-number this.dataSubscribers.paid}} paid</div> --}} </div>
{{!-- <div class="gh-dashboard5-number is-small">{{format-number this.dataSubscribers.free}} free</div> --}}
<h4 class="gh-dashboard5-metric is-split">Email</h4>
<div class="gh-dashboard5-number">{{format-number this.dataSubscribers.total}}</div>
<small class="gh-dashboard5-info">Newsletter subscribers</small> <small class="gh-dashboard5-info">Newsletter subscribers</small>
</div> </div>
@ -23,12 +19,11 @@
<EmberChart <EmberChart
@type={{this.chartType}} @type={{this.chartType}}
@data={{this.chartData}} @data={{this.chartData}}
@options={{this.chartOptions}} @options={{this.chartOptions}} />
@height={{this.chartHeight}} />
{{/if}} {{/if}}
<div> <div>
<div class="gh-dashboard5-number">58%</div> <div class="gh-dashboard5-number">{{this.currentOpenRate}}%</div>
<small class="gh-dashboard5-info">Open rate</small> <small class="gh-dashboard5-info">Open rate</small>
</div> </div>
</div> </div>
</div> </div>

View File

@ -25,6 +25,14 @@ export default class ChartEmailOpenRate extends Component {
}; };
} }
get currentOpenRate() {
if (this.dashboardStats.emailOpenRateStats === null || this.dashboardStats.emailOpenRateStats.length === 0) {
return '-';
}
return this.dashboardStats.emailOpenRateStats[this.dashboardStats.emailOpenRateStats.length - 1].openRate;
}
get dataEmailsSent() { get dataEmailsSent() {
return this.dashboardStats.emailsSent30d ?? 0; return this.dashboardStats.emailsSent30d ?? 0;
} }
@ -62,6 +70,8 @@ export default class ChartEmailOpenRate extends Component {
get chartOptions() { get chartOptions() {
return { return {
responsive: true,
maintainAspectRatio: true,
title: { title: {
display: false display: false
}, },

View File

@ -3,8 +3,7 @@
<article class="gh-dashboard5-box"> <article class="gh-dashboard5-box">
<div class="gh-dashboard5-insert"> <div class="gh-dashboard5-insert">
<div class="gh-dashboard5-box"> <div class="gh-dashboard5-box">
<h4 class="gh-dashboard5-metric is-split">Engagement</h4> <div class="gh-dashboard5-number is-solo">{{this.data30Days}}</div>
<div class="gh-dashboard5-number">{{this.data30Days}}</div>
<small class="gh-dashboard5-info">Last 30 days</small> <small class="gh-dashboard5-info">Last 30 days</small>
</div> </div>

View File

@ -1,16 +1,16 @@
<h4 <small
class="gh-dashboard5-metric is-main" class="gh-dashboard5-info is-solo"
{{did-insert this.loadCharts}} {{did-insert this.loadCharts}}
> >Paid members</small>
Paid members
</h4>
{{#if this.loading}} {{#if this.loading}}
<div class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/> <div class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}} {{else}}
<EmberChart <div class="gh-dashboard5-chart">
@type={{this.chartType}} <EmberChart
@data={{this.chartData}} @type={{this.chartType}}
@options={{this.chartOptions}} @data={{this.chartData}}
@height={{this.chartHeight}} /> @options={{this.chartOptions}}
{{/if}} @height={{150}} />
</div>
{{/if}}

View File

@ -53,6 +53,9 @@ export default class ChartPaidMembers extends Component {
get chartOptions() { get chartOptions() {
return { return {
animation: {
duration: 0
},
title: { title: {
display: false display: false
}, },
@ -105,8 +108,4 @@ export default class ChartPaidMembers extends Component {
} }
}; };
} }
get chartHeight() {
return 100;
}
} }

View File

@ -17,14 +17,18 @@
</div> </div>
{{/if}} {{/if}}
<h4 class="gh-dashboard5-metric is-main" {{did-insert this.loadCharts}}>Paid mix</h4> <small
class="gh-dashboard5-info is-solo"
{{did-insert this.loadCharts}}
>Paid mix</small>
{{#if this.loading}} {{#if this.loading}}
<div class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/> <div class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}} {{else}}
<EmberChart <div class="gh-dashboard5-chart">
@type={{this.chartType}} <EmberChart
@data={{this.chartData}} @type={{this.chartType}}
@options={{this.chartOptions}} @data={{this.chartData}}
@height={{this.chartHeight}} /> @options={{this.chartOptions}} />
</div>
{{/if}} {{/if}}

View File

@ -90,11 +90,12 @@ export default class ChartPaidMix extends Component {
return { return {
legend: { legend: {
display: false display: false
},
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0
} }
}; };
} }
get chartHeight() {
return 100;
}
} }

View File

@ -1,10 +1,3 @@
{{!-- <h4
class="gh-dashboard5-metric is-main"
{{did-insert this.loadCharts}}
{{did-update this.loadCharts @days}}
>
Total members
</h4> --}}
{{#if this.loading}} {{#if this.loading}}
<div {{did-insert this.loadCharts}} class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/> <div {{did-insert this.loadCharts}} class="gh-dashboard5-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}} {{else}}

View File

@ -157,6 +157,15 @@ export default class DashboardStatsService extends Service {
}; };
} }
get currentMRR() {
if (!this.mrrStats) {
return null;
}
const stat = this.mrrStats[this.mrrStats.length - 1];
return stat.mrr;
}
/** /**
* @type {?MemberCounts} * @type {?MemberCounts}
*/ */
@ -192,6 +201,30 @@ export default class DashboardStatsService extends Service {
}; };
} }
get currentMRRTrend() {
if (!this.mrrStats) {
return null;
}
if (this.chartDays === 'all') {
return null;
}
// Search for the value at chartDays ago (if any, else the first before it, or the next one if not one before it)
const searchDate = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
for (let index = this.mrrStats.length - 1; index >= 0; index -= 1) {
const stat = this.mrrStats[index];
if (stat.date <= searchDate) {
return stat.mrr;
}
}
// We don't have any statistic from more than x days ago.
// Return all zero values
return 0;
}
get filledMemberCountStats() { get filledMemberCountStats() {
if (this.memberCountStats === null) { if (this.memberCountStats === null) {
return null; return null;

View File

@ -995,7 +995,7 @@ a.gh-dashboard-container {
.prototype-paid-mix-dropdown { .prototype-paid-mix-dropdown {
position: absolute; position: absolute;
right: 15px; right: 15px;
top: 15px; top: 25px;
} }
.prototype-counts { .prototype-counts {
@ -1093,15 +1093,16 @@ a.gh-dashboard-container {
.gh-dashboard5-number { .gh-dashboard5-number {
display: flex; display: flex;
align-items: flex-start; align-items: flex-end;
font-size: 3rem; font-size: 3rem;
line-height: 4rem; line-height: 4rem;
font-weight: 600; font-weight: 600;
color: #15171a; color: var(--black);
letter-spacing: -.1px; letter-spacing: -.1px;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
margin: 0 0 4px; margin: 0 0 4px;
gap: 10px;
} }
.gh-dashboard5-number.is-small { .gh-dashboard5-number.is-small {
@ -1109,7 +1110,17 @@ a.gh-dashboard-container {
} }
.gh-dashboard5-number.is-solo { .gh-dashboard5-number.is-solo {
margin-top: 46px; margin-top: 28px;
}
.gh-dashboard5-number small {
font-size: 1.4rem;
padding: 0 0 0.25rem;
color: var(--darkgrey);
}
.gh-dashboard5-slash {
margin: 0 0.5em 0 0;
} }
.gh-dashboard5-metric { .gh-dashboard5-metric {
@ -1157,6 +1168,10 @@ a.gh-dashboard-container {
color: var(--midgrey); color: var(--midgrey);
} }
.gh-dashboard5-info.is-solo {
margin: 0 0 20px;
}
.gh-dashboard5-percentage { .gh-dashboard5-percentage {
flex: 0; flex: 0;
background: var(--whitegrey-d1); background: var(--whitegrey-d1);
@ -1167,7 +1182,7 @@ a.gh-dashboard-container {
letter-spacing: 0; letter-spacing: 0;
color: var(--midgrey); color: var(--midgrey);
padding: 2px 4px; padding: 2px 4px;
margin: 5px 0 1px 0; margin: 5px 0 3px 0;
} }
.gh-dashboard5-percentage.is-positive { .gh-dashboard5-percentage.is-positive {
@ -1233,10 +1248,7 @@ a.gh-dashboard-container {
} */ } */
.gh-dashboard5-growth { .gh-dashboard5-growth {
display: grid; display: flex;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: auto auto;
gap: 0;
margin-top: -25px; margin-top: -25px;
} }
@ -1248,22 +1260,24 @@ a.gh-dashboard-container {
} */ } */
.gh-dashboard5-growth .gh-dashboard5-box:nth-child(1) { .gh-dashboard5-growth .gh-dashboard5-box:nth-child(1) {
grid-column: 1 / span 2; /* grid-column: 1;
grid-row: 1 / span 2; grid-row: 1; */
flex: 2;
border-radius: 3px 0 0 0; border-radius: 3px 0 0 0;
border-width: 1px; border-width: 1px;
padding-top: 32px; padding-top: 32px;
} }
.gh-dashboard5-growth .gh-dashboard5-box:nth-child(2) { .gh-dashboard5-growth .gh-dashboard5-box:nth-child(2) {
grid-column: 3 / span 2; /* grid-column: 2;
grid-row: 1; grid-row: 1; */
flex: 1;
border-radius: 0 3px 0 0; border-radius: 0 3px 0 0;
border-width: 1px 1px 1px 0; border-width: 1px 1px 1px 0;
padding-top: 32px; padding-top: 32px;
} }
.gh-dashboard5-growth .gh-dashboard5-box:nth-child(3) { /* .gh-dashboard5-growth .gh-dashboard5-box:nth-child(3) {
grid-column: 3; grid-column: 3;
grid-row: 2; grid-row: 2;
border-radius: 0 0 0 3px; border-radius: 0 0 0 3px;
@ -1275,6 +1289,15 @@ a.gh-dashboard-container {
grid-row: 2; grid-row: 2;
border-radius: 0 0 3px 0; border-radius: 0 0 3px 0;
border-width: 0 1px 1px 0; border-width: 0 1px 1px 0;
} */
.gh-dashboard5-growth {
height: 250px;
}
.gh-dashboard5-growth canvas {
width: 100%;
height: 100%;
} }
.gh-dashboard5 .gh-dashboard-box { .gh-dashboard5 .gh-dashboard-box {
@ -1355,11 +1378,18 @@ a.gh-dashboard-container {
padding: 8px 24px 0 0; padding: 8px 24px 0 0;
} }
.gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:first-child {
justify-content: flex-end;
padding-top: 0;
padding-bottom: 24px;
}
.gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:last-child { .gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:last-child {
border-left-width: 0; border-left-width: 0;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
padding-left: 24px; padding-top: 24px;
padding-bottom: 0;
} }
.gh-dashboard5-split .gh-dashboard5-engagement .gh-dashboard5-insert { .gh-dashboard5-split .gh-dashboard5-engagement .gh-dashboard5-insert {
@ -1375,12 +1405,18 @@ a.gh-dashboard-container {
padding: 8px 24px 24px 0; padding: 8px 24px 24px 0;
} }
.gh-dashboard5-split .gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:first-child {
padding-top: 0;
padding-bottom: 24px;
}
.gh-dashboard5-split .gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:last-child { .gh-dashboard5-split .gh-dashboard5-engagement .gh-dashboard5-insert .gh-dashboard5-box:last-child {
border-bottom-width: 0; border-bottom-width: 0;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
padding-top: 24px;
padding-bottom: 0; padding-bottom: 0;
} }
@ -1474,4 +1510,46 @@ a.gh-dashboard-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
} }
.gh-dashboard5-anchor {
position: relative;
}
.gh-dashboard5-anchor > .gh-dashboard5-box {
padding: 0;
}
.gh-dashboard5-stats {
display: flex;
flex-direction: row;
justify-content: space-evenly;
width: 75%;
padding: 28px;
}
.gh-dashboard5-stats > button {
flex: 1;
border-left: 2px solid #D6EDF9;
padding: 2px 0 0 12px;
margin: 0 8px 0 0;
transition: all 100ms ease-out;
cursor: pointer;
display: block;
text-align: left;
padding-left: 13px;
}
.gh-dashboard5-stats > button.is-selected {
border-color: #14b8ff;
border-width: 3px;
padding-left: 12px;
}
.gh-dashboard5-chart {
display: flex;
flex-direction: row;
justify-content: stretch;
width: 100%;
height: 200px;
}