Stats page refinements (#20924)

[ANAL-43](https://linear.app/tryghost/issue/ANAL-50/update-colors-of-barlist)

- Copy is too technical, doesn't follow conventions on Stats page
- Range filter dropdown has to be updated with more meaningful values
- KPI charts need a granularity dropdown to display meaninful charts
depending on the context
- Typography details should be updated
- "Posts/pages" dropdown needs to be added to Content section. This is a
Ghost specific filter that brings high value to customers
- "Campaigns" dropdown needs to be added to Sources section to support
ad tracking and filtering in the future
- BarList colors should be updated to be less purple all over the place
This commit is contained in:
Peter Zimon 2024-09-05 17:49:20 +02:00 committed by GitHub
parent e3ae2a22b1
commit dd183cf25e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 475 additions and 132 deletions

View File

@ -34,7 +34,7 @@
</li>
{{#if (and (gh-user-can-admin this.session.user) this.config.stats)}}
<li class="relative">
<LinkTo @route="stats">{{svg-jar "stats"}}Stats</LinkTo>
<LinkTo @route="stats">{{svg-jar "stats-outline"}}Stats</LinkTo>
</li>
{{/if}}
{{#if (gh-user-can-admin this.session.user)}}

View File

@ -1 +1 @@
<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience selected=@selected)}}></div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div>

View File

@ -4,19 +4,19 @@ import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {AreaChart, useQuery} from '@tinybirdco/charts';
import {hexToRgba} from 'ghost-admin/utils/stats';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from '../../../utils/stats';
export default class KpisComponent extends Component {
@inject config;
ReactComponent = (props) => {
let chartDays = props.chartDays;
let chartRange = props.chartRange;
let audience = props.audience;
// @TODO: ATM there's a two day worth gap (padding) on the right side
// of the chart. endDate needs to be adjusted to get rid of it
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartDays - 1, 'days').startOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
/**
* @typedef {Object} Params
@ -35,9 +35,9 @@ export default class KpisComponent extends Component {
member_status: audience.length === 0 ? null : audience.join(',')
};
const LINE_COLOR = '#8E42FF';
const LINE_COLOR = statsStaticColors[0];
const INDEX = 'date';
const CATEGORY = props.selected;
const CATEGORY = props.selected === 'unique_visitors' ? 'visits' : props.selected;
const {data, meta, error, loading} = useQuery({
endpoint: `${this.config.stats.endpoint}/v0/pipes/kpis.json`,
@ -45,6 +45,43 @@ export default class KpisComponent extends Component {
params
});
// Create an array with every second date value
const dateLabels = [];
let currentDate = startDate.clone();
let skipDays;
switch (chartRange) {
case 1:
skipDays = 0; // Show all hours for 1 day
break;
case 7:
skipDays = 0; // Skip every other day for 7 days
break;
case (30 + 1):
skipDays = 2; // Skip every 3rd day for 30 and 90 days
break;
case (90 + 1):
skipDays = 5; // Skip every 3rd day for 30 and 90 days
break;
case (365 + 1):
case (12 * (30 + 1)):
skipDays = 30; // Skip every 7th day for 1 year
break;
case 1000:
skipDays = 29; // Skip every 30th day for all time
break;
default:
skipDays = 1; // Default to skipping every other day
}
let dayCounter = 0;
while (currentDate.isSameOrBefore(endDate)) {
if (dayCounter % (skipDays + 1) === 0) {
dateLabels.push(currentDate.format('YYYY-MM-DD'));
}
currentDate.add(1, 'days');
dayCounter = dayCounter + 1;
}
return (
<AreaChart
data={data}
@ -53,7 +90,7 @@ export default class KpisComponent extends Component {
error={error}
index={INDEX}
categories={[CATEGORY]}
colorPalette={[LINE_COLOR, '#008060', '#0EB1B9', '#9263AF', '#5A6FC0']}
colorPalette={[LINE_COLOR]}
backgroundColor="transparent"
fontSize="13px"
textColor="#AEB7C1"
@ -61,27 +98,30 @@ export default class KpisComponent extends Component {
params={params}
options={{
grid: {
left: '0%',
right: '0%',
left: '10px',
right: '10px',
top: '10%',
bottom: 0,
containLabel: true
},
xAxis: {
type: 'time',
// min: startDate.toISOString(),
// max: endDate.toISOString(),
boundaryGap: ['0%', '0.5%'],
min: startDate.toISOString(),
max: endDate.subtract(1, 'day').toISOString(),
boundaryGap: ['0%', '0%'],
axisLabel: {
formatter: chartDays <= 7 ? '{ee}' : '{dd} {MMM}'
formatter: chartRange <= 7 ? '{ee}' : '{d} {MMM}',
customValues: dateLabels
},
axisTick: {
alignWithLabel: true
show: false,
alignWithLabel: true,
interval: 0
},
axisPointer: {
snap: true
},
splitNumber: chartDays <= 7 ? 7 : 5,
splitNumber: dateLabels.length,
splitLine: {
show: false
},
@ -112,7 +152,26 @@ export default class KpisComponent extends Component {
},
extraCssText: 'box-shadow: 0px 100px 80px 0px rgba(0, 0, 0, 0.07), 0px 41.778px 33.422px 0px rgba(0, 0, 0, 0.05), 0px 22.336px 17.869px 0px rgba(0, 0, 0, 0.04), 0px 12.522px 10.017px 0px rgba(0, 0, 0, 0.04), 0px 6.65px 5.32px 0px rgba(0, 0, 0, 0.03), 0px 2.767px 2.214px 0px rgba(0, 0, 0, 0.02);',
formatter: function (fparams) {
return `<div><div>${moment(fparams[0].value[0]).format('DD MMM, YYYY')}</div><div><span style="display: inline-block; margin-right: 16px; font-weight: 600;">Pageviews</span> ${fparams[0].value[1]}</div></div>`;
let displayValue;
let tooltipTitle;
switch (CATEGORY) {
case 'avg_session_sec':
tooltipTitle = 'Visit duration';
displayValue = fparams[0].value[1] !== null && (fparams[0].value[1] / 60).toFixed(0) + ' min';
break;
case 'bounce_rate':
tooltipTitle = 'Bounce rate';
displayValue = fparams[0].value[1] !== null && fparams[0].value[1].toFixed(2) + '%';
break;
default:
tooltipTitle = 'Unique visitors';
displayValue = fparams[0].value[1] !== null && fparams[0].value[1];
break;
}
if (!displayValue) {
displayValue = 'N/A';
}
return `<div><div>${moment(fparams[0].value[0]).format('D MMM, YYYY')}</div><div><span style="display: inline-block; margin-right: 16px; font-weight: 600;">${tooltipTitle}</span> ${displayValue}</div></div>`;
}
},
series: [
@ -123,7 +182,6 @@ export default class KpisComponent extends Component {
type: 'line',
areaStyle: {
opacity: 0.6,
// color: 'rgba(198, 220, 255, 1)'
color: {
type: 'linear',
x: 0,
@ -131,11 +189,11 @@ export default class KpisComponent extends Component {
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(142, 66, 255, 0.3)' // color at 0%
offset: 0, color: hexToRgba(LINE_COLOR, 0.3)
}, {
offset: 1, color: 'rgba(142, 66, 255, 0.0)' // color at 100%
offset: 1, color: hexToRgba(LINE_COLOR, 0.0)
}],
global: false // default is false
global: false
}
},
lineStyle: {
@ -152,11 +210,12 @@ export default class KpisComponent extends Component {
symbol: 'circle',
symbolSize: 10,
z: 8,
smooth: false,
name: props.selected,
smooth: true,
smoothMonotone: 'x',
name: CATEGORY,
data: (data ?? []).map(row => [
String(row[INDEX]),
row[props.selected]
row[CATEGORY]
])
}
]

View File

@ -1 +1 @@
<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience selected=@selected)}}></div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div>

View File

@ -5,15 +5,18 @@ import React from 'react';
import moment from 'moment-timezone';
import {DonutChart, useQuery} from '@tinybirdco/charts';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
export default class KpisComponent extends Component {
@inject config;
ReactComponent = (props) => {
let chartDays = props.chartDays;
let chartRange = props.chartRange;
let audience = props.audience;
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartDays - 1, 'days').startOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
const colorPalette = statsStaticColors.slice(1, 5);
/**
* @typedef {Object} Params
@ -48,8 +51,6 @@ export default class KpisComponent extends Component {
params
});
const colorPalette = ['#B78AFB', '#7FDE8A', '#FBCE75', '#F97DB7', '#6ED0FB'];
let transformedData;
let indexBy;
let tableHead;
@ -59,7 +60,7 @@ export default class KpisComponent extends Component {
transformedData = (data ?? []).map((item, index) => ({
name: item.browser.charAt(0).toUpperCase() + item.browser.slice(1),
value: item.hits,
color: colorPalette[index % colorPalette.length]
color: colorPalette[index]
}));
indexBy = 'browser';
tableHead = 'Browser';
@ -68,7 +69,7 @@ export default class KpisComponent extends Component {
transformedData = (data ?? []).map((item, index) => ({
name: item.device.charAt(0).toUpperCase() + item.device.slice(1),
value: item.hits,
color: colorPalette[index % colorPalette.length]
color: colorPalette[index]
}));
indexBy = 'device';
tableHead = 'Device';

View File

@ -1 +1,2 @@
<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div>
<h5 class="gh-stats-metric-label">Locations</h5>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>

View File

@ -3,16 +3,16 @@ import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
export default class TopLocations extends Component {
@inject config;
ReactComponent = (props) => {
let chartDays = props.chartDays;
let chartRange = props.chartRange;
let audience = props.audience;
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartDays - 1, 'days').startOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
/**
* @typedef {Object} Params
@ -45,7 +45,7 @@ export default class TopLocations extends Component {
loading={loading}
index="location"
categories={['hits']}
colorPalette={['#E8D9FF']}
colorPalette={[statsStaticColors[4]]}
/>
);
};

View File

@ -1 +1,21 @@
<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div>
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Content</h5>
<div>
<PowerSelect
@selected={{this.contentOption}}
@options={{this.contentOptions}}
@searchEnabled={{false}}
@onChange={{this.onContentOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>

View File

@ -4,17 +4,29 @@ import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {CONTENT_OPTIONS} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
import {tracked} from '@glimmer/tracking';
export default class TopPages extends Component {
@inject config;
@tracked contentOption = CONTENT_OPTIONS[0];
@tracked contentOptions = CONTENT_OPTIONS;
@action
onContentOptionChange(selected) {
this.contentOption = selected;
}
ReactComponent = (props) => {
let chartDays = props.chartDays;
let chartRange = props.chartRange;
let audience = props.audience;
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartDays - 1, 'days').startOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
/**
* @typedef {Object} Params
@ -46,8 +58,17 @@ export default class TopPages extends Component {
error={error}
loading={loading}
index="pathname"
indexConfig={{
label: <span style={{fontSize: '12px', fontWeight: 'bold'}}>URL</span>
}}
categories={['hits']}
colorPalette={['#E8D9FF']}
categoryConfig={{
hits: {
label: <span>Visits</span>
// renderValue: ({ value }) => <span>{formatNumber(value)}</span>
}
}}
colorPalette={[statsStaticColors[4]]}
height="300px"
/>
);

View File

@ -1 +1,21 @@
<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div>
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Sources</h5>
<div>
<PowerSelect
@selected={{this.campaignOption}}
@options={{this.campaignOptions}}
@searchEnabled={{false}}
@onChange={{this.onCampaignOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>

View File

@ -4,17 +4,29 @@ import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {CAMPAIGN_OPTIONS} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
import {tracked} from '@glimmer/tracking';
export default class TopPages extends Component {
@inject config;
@tracked campaignOption = CAMPAIGN_OPTIONS[0];
@tracked campaignOptions = CAMPAIGN_OPTIONS;
@action
onCampaignOptionChange(selected) {
this.campaignOption = selected;
}
ReactComponent = (props) => {
let chartDays = props.chartDays;
let chartRange = props.chartRange;
let audience = props.audience;
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartDays - 1, 'days').startOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
/**
* @typedef {Object} Params
@ -47,7 +59,7 @@ export default class TopPages extends Component {
loading={loading}
index="referrer"
categories={['hits']}
colorPalette={['#E8D9FF']}
colorPalette={[statsStaticColors[4]]}
height="300px"
/>
);

View File

@ -1,26 +1,52 @@
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab min-width {{if this.visitsTabSelected 'is-selected'}}" {{on "click" this.changeTabToVisits}}>
<Stats::Parts::Metric
@label="Unique visitors"
@value={{this.totals.visits}} />
</button>
<div class="gh-stats-tabs-header">
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab min-width {{if this.uniqueVisitorsTabSelected 'is-selected'}}" {{on "click" this.changeTabToUniqueVisitors}}>
<Stats::Parts::Metric
@label="Unique visitors"
@value={{this.totals.visits}} />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.pageviewsTabSelected 'is-selected'}}" {{on "click" this.changeTabToPageviews}}>
<Stats::Parts::Metric
@label="Site Pageviews"
@value={{this.totals.pageviews}} />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.visitsTabSelected 'is-selected'}}" {{on "click" this.changeTabToVisits}}>
<Stats::Parts::Metric
@label="Visits"
@value={{this.totals.visits}} />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.avgVisitTimeTabSelected 'is-selected'}}" {{on "click" this.changeTabToAvgVisitTime}}>
<Stats::Parts::Metric
@label="Avg Visit Time"
@value="{{this.totals.avg_session_sec}}m" />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.pageviewsTabSelected 'is-selected'}}" {{on "click" this.changeTabToPageviews}}>
<Stats::Parts::Metric
@label="Pageviews"
@value={{this.totals.pageviews}} />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.bounceRateTabSelected 'is-selected'}}" {{on "click" this.changeTabToBounceRate}}>
<Stats::Parts::Metric
@label="Bounce Rate"
@value="{{this.totals.bounce_rate}}%" />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.bounceRateTabSelected 'is-selected'}}" {{on "click" this.changeTabToBounceRate}}>
<Stats::Parts::Metric
@label="Bounce rate"
@value="{{this.totals.bounce_rate}}%" />
</button>
<button type="button" class="gh-stats-tab min-width {{if this.avgVisitTimeTabSelected 'is-selected'}}" {{on "click" this.changeTabToAvgVisitTime}}>
<Stats::Parts::Metric
@label="Visit duration"
@value="{{this.totals.avg_session_sec}}m" />
</button>
</div>
<div class="gh-stats-kpi-granularity">
{{#if this.showGranularity}}
<PowerSelect
@selected={{this.granularity}}
@options={{this.granularityOptions}}
@searchEnabled={{false}}
@onChange={{this.onGranularityChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
{{/if}}
</div>
</div>
<Stats::Charts::Kpis @chartDays={{@chartDays}} @audience={{@audience}} @selected={{this.selected}} />
<Stats::Charts::Kpis @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} />

View File

@ -7,8 +7,37 @@ import {tracked} from '@glimmer/tracking';
export default class KpisOverview extends Component {
@inject config;
@tracked selected = 'visits';
@tracked selected = 'unique_visitors';
@tracked totals = null;
@tracked showGranularity = true;
get granularityOptions() {
const chartRange = this.args.chartRange;
if (chartRange >= 8 && chartRange <= 30) {
return [
{name: 'Days', value: 'days'},
{name: 'Weeks', value: 'weeks'}
];
} else if (chartRange > 30 && chartRange <= 365) {
return [
{name: 'Days', value: 'days'},
{name: 'Weeks', value: 'weeks'},
{name: 'Months', value: 'months'}
];
} else {
return [
{name: 'Weeks', value: 'weeks'},
{name: 'Months', value: 'months'}
];
}
}
@tracked granularity = this.granularityOptions[0];
@action
onGranularityChange(selected) {
this.granularity = selected;
}
constructor() {
super(...arguments);
@ -73,6 +102,11 @@ export default class KpisOverview extends Component {
document.removeEventListener('visibilitychange', this.fetchData.perform);
}
@action
changeTabToUniqueVisitors() {
this.selected = 'unique_visitors';
}
@action
changeTabToVisits() {
this.selected = 'visits';
@ -93,6 +127,10 @@ export default class KpisOverview extends Component {
this.selected = 'bounce_rate';
}
get uniqueVisitorsTabSelected() {
return (this.selected === 'unique_visitors');
}
get visitsTabSelected() {
return (this.selected === 'visits');
}

View File

@ -1,13 +1,8 @@
import Component from '@glimmer/component';
import {AUDIENCE_TYPES} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export const AUDIENCE_TYPES = [
{name: 'Logged out visitors', value: 'undefined'},
{name: 'Free members', value: 'free'},
{name: 'Paid members', value: 'paid'}
];
function toggleAudienceType(audicenceType, audicenceTypes) {
const excludedAudiences = new Set(audicenceTypes.filter(type => !type.isSelected).map(type => type.value));
if (excludedAudiences.has(audicenceType)) {

View File

@ -1,18 +1,20 @@
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}>
<Stats::Parts::Metric
@label="Devices" />
</button>
<div class="gh-stats-tabs-header">
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}>
<Stats::Parts::Metric
@label="Devices" />
</button>
<button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}>
<Stats::Parts::Metric
@label="Browsers" />
</button>
<button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}>
<Stats::Parts::Metric
@label="Browsers" />
</button>
{{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}>
<Stats::Parts::Metric
@label="Operating systems" />
</button> --}}
{{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}>
<Stats::Parts::Metric
@label="Operating systems" />
</button> --}}
</div>
</div>
<Stats::Charts::Technical @chartDays={{@chartDays}} @audience={{@audience}} @selected={{this.selected}} />
<Stats::Charts::Technical @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} />

View File

@ -1,29 +1,16 @@
import Controller from '@ember/controller';
import {AUDIENCE_TYPES} from 'ghost-admin/components/stats/parts/audience-filter';
import {AUDIENCE_TYPES, RANGE_OPTIONS} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
// Options 30 and 90 need an extra day to be able to distribute ticks/gridlines evenly
const DAYS_OPTIONS = [{
name: '7 Days',
value: 7
}, {
name: '30 Days',
value: 30 + 1
}, {
name: '90 Days',
value: 90 + 1
}];
export default class StatsController extends Controller {
daysOptions = DAYS_OPTIONS;
rangeOptions = RANGE_OPTIONS;
audienceOptions = AUDIENCE_TYPES;
/**
* @type {number|'all'}
* Amount of days to load for member count and MRR related charts
* Date range to load for member count and MRR related charts
*/
@tracked chartDays = 30 + 1;
@tracked chartRange = 30 + 1;
/**
* @type {array}
* Filter by audience
@ -32,8 +19,8 @@ export default class StatsController extends Controller {
@tracked excludedAudiences = '';
@action
onDaysChange(selected) {
this.chartDays = selected.value;
onRangeChange(selected) {
this.chartRange = selected.value;
}
@action
@ -50,7 +37,7 @@ export default class StatsController extends Controller {
}
}
get selectedDaysOption() {
return this.daysOptions.find(d => d.value === this.chartDays);
get selectedRangeOption() {
return this.rangeOptions.find(d => d.value === this.chartRange);
}
}

View File

@ -1,10 +1,18 @@
.gh-stats {
.gh-stats .view-container {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 32px !important;
}
.gh-stats .view-actions {
flex-direction: row !important;
}
.gh-stats .gh-canvas-header-content {
min-height: unset !important;
}
.gh-stats-grid {
display: grid;
gap: 32px;
@ -22,7 +30,8 @@
transition: all ease-in-out 0.3s;
}
.gh-stats-container > .gh-stats-metric-label {
.gh-stats-container > .gh-stats-metric-label,
.gh-stats-metric-header {
margin-bottom: 20px;
}
@ -30,15 +39,16 @@
box-shadow: 0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,.03), 0 8px 10px -8px rgba(0,0,0,.1);
}
.gh-stats-tabs {
.gh-stats-tabs-header {
position: relative;
display: flex;
margin: -20px -20px 20px;
padding: 0;
overflow: hidden;
justify-content: space-between;
}
.gh-stats-tabs:before {
.gh-stats-tabs-header:before {
display: block;
content: "";
position: absolute;
@ -50,6 +60,10 @@
background: var(--whitegrey);
}
.gh-stats-tabs {
display: flex;
}
.gh-stats-tab {
position: relative;
margin: 0 0 1px 0;
@ -89,6 +103,12 @@
min-width: 180px;
}
.gh-stats-metric-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.gh-stats-metric-data {
display: flex;
flex-direction: column;
@ -103,7 +123,7 @@
}
.gh-stats-metric-value {
font-size: 28px;
font-size: 26px;
letter-spacing: -0.05em;
font-weight: 600;
line-height: 1;
@ -134,4 +154,34 @@
height: 100%;
flex-grow: 1;
margin-right: -20px;
}
.gh-stats-section-dropdown {
padding: 0 8px 0 0 !important;
height: 28px;
border: none !important;
cursor: pointer;
overflow: hidden;
}
.gh-stats-section-dropdown.ember-power-select-trigger.gh-btn span {
padding-right: 4px;
}
.gh-stats-section-dropdown.ember-power-select-trigger:not(.ember-power-select-multiple-trigger):not(.gh-preview-newsletter-trigger) svg {
margin-top: 0;
}
.gh-stats-kpi-granularity {
padding: 12px 20px;
}
@media (max-width: 1440px) {
.gh-stats-tab.min-width .gh-stats-metric {
min-width: 150px;
}
.gh-stats-metric-label {
font-size: 14px;
}
}

View File

@ -1,4 +1,4 @@
<section class="gh-canvas gh-canvas-sticky">
<section class="gh-stats gh-canvas gh-canvas-sticky">
<GhCanvasHeader class="gh-canvas-header sticky break tablet post-header">
<GhCustomViewTitle @title="Stats" />
<div class="view-actions">
@ -8,10 +8,10 @@
/>
<PowerSelect
@selected={{this.selectedDaysOption}}
@options={{this.daysOptions}}
@selected={{this.selectedRangeOption}}
@options={{this.rangeOptions}}
@searchEnabled={{false}}
@onChange={{this.onDaysChange}}
@onChange={{this.onRangeChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@ -24,29 +24,26 @@
</div>
</GhCanvasHeader>
<section class="view-container gh-stats">
<section class="view-container">
<section class="gh-stats-container">
<Stats::KpisOverview @chartDays={{this.chartDays}} @audience={{this.audience}} />
<Stats::KpisOverview @chartRange={{this.chartRange}} @audience={{this.audience}} />
</section>
<section class="gh-stats-grid cols-2">
<div class="gh-stats-container">
<h5 class="gh-stats-metric-label">Top posts & pages</h5>
<Stats::Charts::TopPages @chartDays={{this.chartDays}} @audience={{this.audience}} />
<Stats::Charts::TopPages @chartRange={{this.chartRange}} @audience={{this.audience}} />
</div>
<div class="gh-stats-container">
<h5 class="gh-stats-metric-label">Sources</h5>
<Stats::Charts::TopSources @chartDays={{this.chartDays}} @audience={{this.audience}} />
<Stats::Charts::TopSources @chartRange={{this.chartRange}} @audience={{this.audience}} />
</div>
</section>
<section class="gh-stats-grid cols-2">
<div class="gh-stats-container">
<h5 class="gh-stats-metric-label">Top locations</h5>
<Stats::Charts::TopLocations @chartDays={{this.chartDays}} @audience={{this.audience}} />
<Stats::Charts::TopLocations @chartRange={{this.chartRange}} @audience={{this.audience}} />
</div>
<div class="gh-stats-container">
<Stats::TechnicalOverview @chartDays={{this.chartDays}} @audience={{this.audience}} />
<Stats::TechnicalOverview @chartRange={{this.chartRange}} @audience={{this.audience}} />
</div>
</section>

View File

@ -0,0 +1,111 @@
export const RANGE_OPTIONS = [
{name: 'Last 24 hours', value: 1},
{name: 'Last 7 days', value: 7},
{name: 'Last 30 days', value: 30 + 1},
{name: 'Last 3 months', value: 90 + 1},
{name: 'Year to date', value: 365 + 1},
{name: 'Last 12 months', value: 12 * (30 + 1)},
{name: 'All time', value: 1000}
];
export const CONTENT_OPTIONS = [
{name: 'Posts & pages', value: 'all'},
{name: 'Posts', value: 'posts'},
{name: 'Pages', value: 'pages'}
];
export const CAMPAIGN_OPTIONS = [
{name: 'All campaigns', value: 'all'},
{name: 'UTM Medium', value: 'utm-medium'},
{name: 'UTM Source', value: 'utm-source'},
{name: 'UTM Campaign', value: 'utm-campaign'},
{name: 'UTM Content', value: 'utm-content'},
{name: 'UTM Term', value: 'utm-term'}
];
export const AUDIENCE_TYPES = [
{name: 'Logged out visitors', value: 'undefined'},
{name: 'Free members', value: 'free'},
{name: 'Paid members', value: 'paid'}
];
export function hexToRgba(hex, alpha = 1) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export function generateMonochromePalette(baseColor, count = 10) {
// Convert hex to RGB
let r = parseInt(baseColor.slice(1, 3), 16);
let g = parseInt(baseColor.slice(3, 5), 16);
let b = parseInt(baseColor.slice(5, 7), 16);
// Convert RGB to HSL
r /= 255, g /= 255, b /= 255;
let max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
// Generate palette
let palette = [];
for (let i = 0; i < count; i++) {
// Adjust the range based on the base color's lightness
let rangeStart, rangeEnd;
if (l < 0.5) {
// For darker base colors
rangeStart = 0.1;
rangeEnd = 0.7;
} else {
// For lighter base colors
rangeStart = 0.3;
rangeEnd = 0.9;
}
let newL = rangeStart + (i / (count - 1)) * (rangeEnd - rangeStart);
// Convert back to RGB
let c = (1 - Math.abs(2 * newL - 1)) * s;
let x = c * (1 - Math.abs((h * 6) % 2 - 1));
let m = newL - c / 2;
if (0 <= h && h < 1 / 6) {
[r, g, b] = [c, x, 0];
} else if (1 / 6 <= h && h < 2 / 6) {
[r, g, b] = [x, c, 0];
} else if (2 / 6 <= h && h < 3 / 6) {
[r, g, b] = [0, c, x];
} else if (3 / 6 <= h && h < 4 / 6) {
[r, g, b] = [0, x, c];
} else if (4 / 6 <= h && h < 5 / 6) {
[r, g, b] = [x, 0, c];
} else {
[r, g, b] = [c, 0, x];
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
palette.push(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`);
}
return palette;
}
export const statsStaticColors = [
'#8E42FF', '#B07BFF', '#C7A0FF', '#DDC6FF', '#EBDDFF', '#F7EDFF'
];

View File

@ -44,7 +44,7 @@
"@sentry/ember": "7.119.0",
"@sentry/integrations": "7.114.0",
"@sentry/replay": "7.116.0",
"@tinybirdco/charts": "0.1.8",
"@tinybirdco/charts": "0.2.0-beta.2",
"@tryghost/color-utils": "0.2.2",
"@tryghost/ember-promise-modals": "2.0.1",
"@tryghost/helpers": "1.1.90",

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" id="Analytics-Board-Graph-Line--Streamline-Ultimate">
<path d="M1.836 3.02652C1.6269600000000002 3.056808 1.384152 3.1353120000000003 1.177176 3.239544C0.626064 3.517104 0.250848 4.013688 0.064176 4.712664L0.012 4.908 0.012 12.036L0.012 19.164 0.066144 19.368000000000002C0.277536 20.164536 0.8438640000000001 20.703792 1.7126640000000002 20.935824L1.9080000000000001 20.988 12.036 20.988L22.164 20.988 22.368000000000002 20.934408C22.4802 20.90496 22.682472 20.826336 22.817496000000002 20.759712C23.017176 20.661216 23.103144 20.6004 23.27808 20.433936C23.396376 20.321352 23.53104 20.172192000000003 23.577312000000003 20.102448C23.768352 19.814664 23.934383999999998 19.394351999999998 23.967144 19.115664C23.975568 19.043904 23.991816 18.990936 24.00324 18.997992C24.014904 19.005216 24.024 15.940655999999999 24.024 12.002832C24.024 7.356719999999999 24.015912 4.98984 24 4.98C23.986800000000002 4.97184 23.976 4.929744 23.976 4.886424C23.976 4.751832 23.883912000000002 4.437215999999999 23.782128 4.224C23.508816 3.651576 23.021664 3.2703599999999997 22.300488 3.064632L22.116 3.012 12.048 3.008832C6.451512 3.007056 1.91604 3.0149280000000003 1.836 3.02652M1.9369679999999998 4.550688C1.7866799999999998 4.596888 1.652928 4.732272 1.58076 4.91124L1.524 5.0520000000000005 1.524 12.036L1.524 19.02 1.581192 19.13616C1.6518000000000002 19.279608 1.781568 19.376976000000003 1.98648 19.440264C2.13972 19.487592 2.226672 19.488 12.034176 19.488C20.913984000000003 19.488 21.940152 19.484184 22.052976 19.450680000000002C22.214328 19.402752 22.344504 19.274136000000002 22.41924 19.08876L22.476 18.948 22.476 11.964L22.476 4.98 22.418808 4.86384C22.3482 4.720392 22.218432 4.623024 22.01352 4.559736C21.860232 4.512408 21.775728 4.512024 11.95548 4.51368C3.3041760000000004 4.51512 2.037456 4.519824 1.9369679999999998 4.550688M0.011832 12.024000000000001C0.011832 15.885 0.014616 17.464512 0.018000000000000002 15.534C0.021384 13.603512 0.021384 10.444512 0.018000000000000002 8.514C0.014616 6.583512 0.011832 8.163 0.011832 12.024000000000001M20.081616 7.512912C19.939968 7.548168 19.820496000000002 7.619136 19.712712000000003 7.7320079999999995C19.658112 7.7892 18.725328 9.1698 17.639856 10.8C16.554384 12.4302 15.656568 13.774464000000002 15.644712000000002 13.787232C15.631319999999999 13.801656 15.155952000000001 13.34484 14.393568 12.584928C12.993264 11.189184000000001 13.060248000000001 11.241816 12.709152 11.261112C12.571488 11.268672 12.484968 11.287464 12.408 11.326584C12.333816 11.364264 11.928431999999999 11.75196 11.113728 12.564336L9.92748 13.747224000000001 8.733984 11.547624C8.077584 10.337832 7.503096 9.304584 7.457376 9.251520000000001C7.1898480000000005 8.941032 6.685752 8.917463999999999 6.3916319999999995 9.20172C6.268944 9.32028 3.093816 14.600304 3.0325200000000003 14.787672C2.9826 14.940287999999999 2.99928 15.17376 3.070584 15.321096C3.143808 15.47232 3.293304 15.618096000000001 3.447672 15.688775999999999C3.5611680000000003 15.740736000000002 3.60528 15.747624 3.775416 15.739991999999999C4.011408 15.729432000000001 4.160880000000001 15.663816 4.294344000000001 15.512184000000001C4.342872 15.457056 4.931544 14.49576 5.602488 13.375968C6.273432 12.256176 6.834456 11.32716 6.8491919999999995 11.311488C6.8693040000000005 11.290104 7.152216 11.792976000000001 7.98 13.321319999999998C8.587200000000001 14.442408000000002 9.122112000000001 15.410856 9.168696 15.473400000000002C9.291288 15.638088 9.480072 15.729432000000001 9.72036 15.740352C10.061904 15.755904 9.993816 15.810312000000001 11.453784 14.354832000000002L12.743568 13.069032 14.021784 14.343792C14.903927999999999 15.22356 15.333456 15.635568 15.408 15.67344C15.485327999999999 15.712704 15.571536 15.731328 15.711552000000001 15.739032C15.885672000000001 15.748584000000001 15.922488000000001 15.742728 16.047552 15.685535999999999C16.124807999999998 15.650184000000001 16.219032000000002 15.5922 16.256976 15.556632C16.34448 15.474672 20.867520000000003 8.695488000000001 20.943288 8.532792C21.102263999999998 8.191368 20.923608 7.734336000000001 20.565672 7.566840000000001C20.43624 7.506264000000001 20.209656 7.481016 20.081616 7.512912" stroke="none" fill="currentColor" fill-rule="evenodd"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -7313,10 +7313,10 @@
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd"
integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==
"@tinybirdco/charts@0.1.8":
version "0.1.8"
resolved "https://registry.yarnpkg.com/@tinybirdco/charts/-/charts-0.1.8.tgz#536bee44d36602f5c123757659c7f8ead3473494"
integrity sha512-2iSup8nP0cXbh6LVhD+xoJLCoT0hHUOAYGgN3r7ts1ml6OgL7Vxsg7cVwr0jqtPP4ifSW2DBqob6E6pMd3rThg==
"@tinybirdco/charts@0.2.0-beta.2":
version "0.2.0-beta.2"
resolved "https://registry.yarnpkg.com/@tinybirdco/charts/-/charts-0.2.0-beta.2.tgz#0b3ed104d23496bbd4809ee49852f522a3636dfe"
integrity sha512-05gLrdiM3kc4QJpDvideX0BjP++SfQ+K6XhxglE8wT/84TkzWpX1ouexyFc5FdsHTnGqeiVx+lhMMwSkN8G6Vg==
dependencies:
echarts "^5.5.0"
swr "^2.2.5"