diff --git a/assets/css/app.css b/assets/css/app.css index 0d8344c7b..34b6c29e7 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -95,9 +95,8 @@ blockquote { position: absolute; left: 50%; top: 50%; - transform: translateX(-50%) translateY(-50%); - width: 20px; - height: 20px; + width: 10px; + height: 10px; } .pulsating-circle:before { @@ -110,8 +109,8 @@ blockquote { margin-left: -100%; margin-top: -100%; border-radius: 45px; - background-color: #01a4e9; - animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; + background-color: #9ae6b4; + animation: pulse-ring 3s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; @apply bg-green-500; } .pulsating-circle:after { @@ -124,7 +123,7 @@ blockquote { height: 100%; background-color: white; border-radius: 15px; - animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite; + animation: pulse-dot 3s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite; @apply bg-green-500; } @@ -133,7 +132,10 @@ blockquote { 0% { transform: scale(.33); } - 80%, 100% { + 50% { + transform: scale(1); + } + 40%, 100% { opacity: 0; } } @@ -142,10 +144,10 @@ blockquote { 0% { transform: scale(.8); } - 50% { + 25% { transform: scale(1); } - 100% { + 50%, 100% { transform: scale(.8); } } diff --git a/assets/js/dashboard/datepicker.js b/assets/js/dashboard/datepicker.js index f2747502a..e4fe67cf6 100644 --- a/assets/js/dashboard/datepicker.js +++ b/assets/js/dashboard/datepicker.js @@ -87,6 +87,8 @@ class DatePicker extends React.Component { return 'Last 6 months' } else if (query.period === '12mo') { return 'Last 12 months' + } else if (query.period === 'realtime') { + return 'Realtime' } else if (query.period === 'custom') { return `${formatDayShort(query.from)} - ${formatDayShort(query.to)}` } @@ -171,6 +173,7 @@ class DatePicker extends React.Component {
{ this.renderLink('day', 'Today') } + { this.renderLink('realtime', 'Realtime') }
diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js new file mode 100644 index 000000000..b0a25dd97 --- /dev/null +++ b/assets/js/dashboard/historical.js @@ -0,0 +1,48 @@ +import React from 'react'; + +import Datepicker from './datepicker' +import Filters from './filters' +import CurrentVisitors from './stats/current-visitors' +import VisitorGraph from './stats/visitor-graph' +import Referrers from './stats/referrers' +import Pages from './stats/pages' +import Countries from './stats/countries' +import Devices from './stats/devices' +import Conversions from './stats/conversions' + +export default class Historical extends React.Component { + renderConversions() { + if (this.props.site.hasGoals) { + return ( +
+ +
+ ) + } + } + + render() { + return ( +
+
+
+

Analytics for {this.props.site.domain}

+ +
+ +
+ + +
+ + +
+
+ + +
+ { this.renderConversions() } +
+ ) + } +} diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index cf0bb2b17..5cdcea91d 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -1,22 +1,38 @@ import React from 'react'; import { withRouter } from 'react-router-dom' -import Datepicker from './datepicker' -import Filters from './filters' -import CurrentVisitors from './stats/current-visitors' -import VisitorGraph from './stats/visitor-graph' -import Referrers from './stats/referrers' -import Pages from './stats/pages' -import Countries from './stats/countries' -import Devices from './stats/devices' -import Conversions from './stats/conversions' +import Historical from './historical' +import Realtime from './realtime' import {parseQuery} from './query' import * as api from './api' -class Stats extends React.Component { + +const THIRTY_SECONDS = 30000 + +class Timer { + constructor() { + this.listeners = [] + this.intervalId = setInterval(this.dispatchTick.bind(this), THIRTY_SECONDS) + } + + onTick(listener) { + this.listeners.push(listener) + } + + dispatchTick() { + for (const listener of this.listeners) { + listener() + } + } +} + +class Dashboard extends React.Component { constructor(props) { super(props) - this.state = {query: parseQuery(props.location.search, this.props.site)} + this.state = { + query: parseQuery(props.location.search, this.props.site), + timer: new Timer() + } } componentDidUpdate(prevProps) { @@ -26,40 +42,13 @@ class Stats extends React.Component { } } - renderConversions() { - if (this.props.site.hasGoals) { - return ( -
- -
- ) - } - } - render() { - return ( -
-
-
-

Analytics for {this.props.site.domain}

- -
- -
- - -
- - -
-
- - -
- { this.renderConversions() } -
- ) + if (this.state.query.period === 'realtime') { + return + } else { + return + } } } -export default withRouter(Stats) +export default withRouter(Dashboard) diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index 047e045e4..070c9edbf 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -1,6 +1,6 @@ import {formatDay, formatMonthYYYY, nowInOffset, parseUTCDate} from './date' -const PERIODS = ['day', 'month', '7d', '30d', '60d', '6mo', '12mo', 'custom'] +const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '60d', '6mo', '12mo', 'custom'] export function parseQuery(querystring, site) { const q = new URLSearchParams(querystring) @@ -8,7 +8,7 @@ export function parseQuery(querystring, site) { const periodKey = 'period__' + site.domain if (PERIODS.includes(period)) { - if (period !== 'custom') window.localStorage[periodKey] = period + if (period !== 'custom' && period !== 'realtime') window.localStorage[periodKey] = period } else { if (window.localStorage[periodKey]) { period = window.localStorage[periodKey] diff --git a/assets/js/dashboard/realtime.js b/assets/js/dashboard/realtime.js new file mode 100644 index 000000000..927e0c8f0 --- /dev/null +++ b/assets/js/dashboard/realtime.js @@ -0,0 +1,48 @@ +import React from 'react'; + +import Datepicker from './datepicker' +import Filters from './filters' +import CurrentVisitors from './stats/current-visitors' +import VisitorGraph from './stats/visitor-graph' +import Referrers from './stats/referrers' +import Pages from './stats/pages' +import Countries from './stats/countries' +import Devices from './stats/devices' +import Conversions from './stats/conversions' + +export default class Stats extends React.Component { + renderConversions() { + if (this.props.site.hasGoals) { + return ( +
+ +
+ ) + } + } + + render() { + return ( +
+
+
+

Analytics for {this.props.site.domain}

+
+ +
+ + +
+ + +
+
+ + +
+ + { this.renderConversions() } +
+ ) + } +} diff --git a/assets/js/dashboard/stats/conversions.js b/assets/js/dashboard/stats/conversions.js index 84c619267..f87f859c1 100644 --- a/assets/js/dashboard/stats/conversions.js +++ b/assets/js/dashboard/stats/conversions.js @@ -28,15 +28,27 @@ export default class Conversions extends React.Component { .then((res) => this.setState({loading: false, goals: res})) } - renderGoal(goal) { - const query = new URLSearchParams(window.location.search) - query.set('goal', goal.name) + renderGoalText(goalName) { + if (this.props.query.period === 'realtime') { + return {goalName} + } else { + const query = new URLSearchParams(window.location.search) + query.set('goal', goalName) + return ( + + { goalName } + + ) + } + } + + renderGoal(goal) { return (
- { goal.name } + {this.renderGoalText(goal.name)}
{numberFormatter(goal.count)}
@@ -53,7 +65,7 @@ export default class Conversions extends React.Component { } else if (this.state.goals) { return (
-

Goal Conversions

+

{this.props.title || "Goal Conversions"}

Goal Conversions diff --git a/assets/js/dashboard/stats/countries.js b/assets/js/dashboard/stats/countries.js index c3eeca948..2f8941572 100644 --- a/assets/js/dashboard/stats/countries.js +++ b/assets/js/dashboard/stats/countries.js @@ -10,14 +10,13 @@ export default class Countries extends React.Component { constructor(props) { super(props) this.resizeMap = this.resizeMap.bind(this) - this.state = { - loading: true - } + this.state = {loading: true} } componentDidMount() { - this.fetchCountries() + this.fetchCountries().then(this.drawMap.bind(this)) window.addEventListener('resize', this.resizeMap); + if (this.props.timer) this.props.timer.onTick(this.updateCountries.bind(this)) } componentWillUnmount() { @@ -31,17 +30,7 @@ export default class Countries extends React.Component { } } - fetchCountries() { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query) - .then((res) => this.setState({loading: false, countries: res})) - .then(() => this.drawMap()) - } - - resizeMap() { - this.map && this.map.resize() - } - - drawMap() { + getDataset() { var dataset = {}; var onlyValues = this.state.countries.map(function(obj){ return obj.count }); @@ -56,6 +45,28 @@ export default class Countries extends React.Component { dataset[item.name] = {numberOfThings: item.count, fillColor: paletteScale(item.count)}; }); + return dataset + } + + updateCountries() { + this.fetchCountries().then(() => { + this.map.updateChoropleth(this.getDataset(), {reset: true}) + }) + } + + fetchCountries() { + return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query) + .then((res) => this.setState({loading: false, countries: res})) + } + + resizeMap() { + this.map && this.map.resize() + } + + drawMap() { + var dataset = this.getDataset(); + const label = this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + this.map = new Datamap({ element: document.getElementById('map-container'), responsive: true, @@ -73,7 +84,7 @@ export default class Countries extends React.Component { if (!data) { return ; } return ['
', '', geo.properties.name, '', - '
', data.numberOfThings, ' Visitors', + '
', data.numberOfThings, ' ' + label, '
'].join(''); } } diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js index 4adafd514..c058a28f3 100644 --- a/assets/js/dashboard/stats/current-visitors.js +++ b/assets/js/dashboard/stats/current-visitors.js @@ -1,6 +1,5 @@ import React from 'react'; - -const THIRTY_SECONDS = 30000 +import { Link } from 'react-router-dom' export default class CurrentVisitors extends React.Component { constructor(props) { @@ -9,13 +8,8 @@ export default class CurrentVisitors extends React.Component { } componentDidMount() { - this.updateCount().then(() => { - this.intervalId = setInterval(this.updateCount.bind(this), THIRTY_SECONDS) - }) - } - - componentWillUnMount() { - clearInverval(this.intervalId) + this.updateCount() + this.props.timer.onTick(this.updateCount.bind(this)) } updateCount() { @@ -30,12 +24,12 @@ export default class CurrentVisitors extends React.Component { render() { if (this.state.currentVisitors !== null) { return ( -
+ {this.state.currentVisitors} current visitors -
+ ) } else { return null diff --git a/assets/js/dashboard/stats/devices.js b/assets/js/dashboard/stats/devices.js index f8dfffaa0..d05a68619 100644 --- a/assets/js/dashboard/stats/devices.js +++ b/assets/js/dashboard/stats/devices.js @@ -46,6 +46,7 @@ class ScreenSizes extends React.Component { componentDidMount() { this.fetchScreenSizes() + if (this.props.timer) this.props.timer.onTick(this.fetchScreenSizes.bind(this)) } componentDidUpdate(prevProps) { @@ -72,13 +73,17 @@ class ScreenSizes extends React.Component { ) } + label() { + return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderList() { if (this.state.sizes && this.state.sizes.length > 0) { return (
Screen size - Visitors + { this.label() }
{ this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) }
@@ -108,6 +113,7 @@ class Browsers extends React.Component { componentDidMount() { this.fetchBrowsers() + if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this)) } componentDidUpdate(prevProps) { @@ -134,13 +140,17 @@ class Browsers extends React.Component { ) } + label() { + return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderList() { if (this.state.browsers && this.state.browsers.length > 0) { return (
Browser - Visitors + { this.label() }
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
@@ -170,6 +180,7 @@ class OperatingSystems extends React.Component { componentDidMount() { this.fetchOperatingSystems() + if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this)) } componentDidUpdate(prevProps) { @@ -196,13 +207,17 @@ class OperatingSystems extends React.Component { ) } + label() { + return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderList() { if (this.state.operatingSystems && this.state.operatingSystems.length > 0) { return (
Operating system - Visitors + { this.label() }
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
@@ -232,11 +247,11 @@ export default class Devices extends React.Component { renderContent() { if (this.state.mode === 'size') { - return + return } else if (this.state.mode === 'browser') { - return + return } else if (this.state.mode === 'os') { - return + return } } diff --git a/assets/js/dashboard/stats/modals/countries.js b/assets/js/dashboard/stats/modals/countries.js index 73cc071e9..ada725266 100644 --- a/assets/js/dashboard/stats/modals/countries.js +++ b/assets/js/dashboard/stats/modals/countries.js @@ -10,13 +10,14 @@ import {parseQuery} from '../../query' class CountriesModal extends React.Component { constructor(props) { super(props) - this.state = {loading: true} + this.state = { + loading: true, + query: parseQuery(props.location.search, props.site) + } } componentDidMount() { - const query = parseQuery(this.props.location.search, this.props.site) - - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, query, {limit: 100}) + api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 100}) .then((res) => this.setState({loading: false, countries: res})) } @@ -29,6 +30,10 @@ class CountriesModal extends React.Component { ) } + label() { + return this.state.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderBody() { if (this.state.loading) { return ( @@ -45,7 +50,7 @@ class CountriesModal extends React.Component { Country - Visitors + {this.label()} diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 0309b9a6f..82a295bb2 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -23,7 +23,7 @@ class PagesModal extends React.Component { } showBounceRate() { - return !this.state.query.filters.goal + return this.state.query.period !== 'realtime' && !this.state.query.filters.goal } formatBounceRate(page) { @@ -44,6 +44,10 @@ class PagesModal extends React.Component { ) } + label() { + return this.state.query.period === 'realtime' ? 'Active visitors' : 'Pageviews' + } + renderBody() { if (this.state.loading) { return ( @@ -60,7 +64,7 @@ class PagesModal extends React.Component { Page url - Pageviews + { this.label() } {this.showBounceRate() && Bounce rate} diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 4374703bf..e98b48c6c 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -29,7 +29,7 @@ class ReferrerDrilldownModal extends React.Component { } showBounceRate() { - return !this.state.query.filters.goal + return this.state.query.period !== 'realtime' && !this.state.query.filters.goal } formatBounceRate(ref) { diff --git a/assets/js/dashboard/stats/modals/referrers.js b/assets/js/dashboard/stats/modals/referrers.js index 01d462727..773045474 100644 --- a/assets/js/dashboard/stats/modals/referrers.js +++ b/assets/js/dashboard/stats/modals/referrers.js @@ -29,7 +29,7 @@ class ReferrersModal extends React.Component { } showBounceRate() { - return !this.state.query.filters.goal + return this.state.query.period !== 'realtime' && !this.state.query.filters.goal } formatBounceRate(page) { @@ -53,6 +53,10 @@ class ReferrersModal extends React.Component { ) } + label() { + return this.state.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderBody() { if (this.state.loading) { return ( @@ -69,7 +73,7 @@ class ReferrersModal extends React.Component { Referrer - Visitors + {this.label()} {this.showBounceRate() && Bounce rate} diff --git a/assets/js/dashboard/stats/pages.js b/assets/js/dashboard/stats/pages.js index 9c5aeb0b8..00190c84b 100644 --- a/assets/js/dashboard/stats/pages.js +++ b/assets/js/dashboard/stats/pages.js @@ -1,4 +1,5 @@ import React from 'react'; +import FlipMove from 'react-flip-move'; import FadeIn from '../fade-in' import Bar from './bar' @@ -10,13 +11,12 @@ import * as api from '../api' export default class Pages extends React.Component { constructor(props) { super(props) - this.state = { - loading: true - } + this.state = {loading: true} } componentDidMount() { this.fetchPages() + if (this.props.timer) this.props.timer.onTick(this.fetchPages.bind(this)) } componentDidUpdate(prevProps) { @@ -43,16 +43,22 @@ export default class Pages extends React.Component { ) } + label() { + return this.props.query.period === 'realtime' ? 'Active visitors' : 'Pageviews' + } + renderList() { if (this.state.pages.length > 0) { return (
Page url - Pageviews + { this.label() }
- { this.state.pages.map(this.renderPage.bind(this)) } + + { this.state.pages.map(this.renderPage.bind(this)) } +
) } else { diff --git a/assets/js/dashboard/stats/referrers.js b/assets/js/dashboard/stats/referrers.js index e5f7581b8..f99ad0dbb 100644 --- a/assets/js/dashboard/stats/referrers.js +++ b/assets/js/dashboard/stats/referrers.js @@ -1,5 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom' +import FlipMove from 'react-flip-move'; import FadeIn from '../fade-in' import Bar from './bar' @@ -15,6 +16,7 @@ export default class Referrers extends React.Component { componentDidMount() { this.fetchReferrers() + if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this)) } componentDidUpdate(prevProps) { @@ -49,16 +51,22 @@ export default class Referrers extends React.Component { ) } + label() { + return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors' + } + renderList() { if (this.state.referrers.length > 0) { return (
Referrer - Visitors + { this.label() }
- {this.state.referrers.map(this.renderReferrer.bind(this))} + + {this.state.referrers.map(this.renderReferrer.bind(this))} +
) } else { diff --git a/assets/js/dashboard/stats/visitor-graph.js b/assets/js/dashboard/stats/visitor-graph.js index a2a6eadde..243756b84 100644 --- a/assets/js/dashboard/stats/visitor-graph.js +++ b/assets/js/dashboard/stats/visitor-graph.js @@ -6,7 +6,7 @@ import { eventName } from '../query' import numberFormatter from '../number-formatter' import * as api from '../api' -function mainSet(plot, present_index, ctx) { +function mainSet(plot, present_index, ctx, label) { var gradient = ctx.createLinearGradient(0, 0, 0, 300); gradient.addColorStop(0, 'rgba(101,116,205, 0.2)'); gradient.addColorStop(1, 'rgba(101,116,205, 0)'); @@ -19,7 +19,7 @@ function mainSet(plot, present_index, ctx) { } return [{ - label: 'Visitors', + label: label, data: plot, borderWidth: 3, borderColor: 'rgba(101,116,205)', @@ -27,7 +27,7 @@ function mainSet(plot, present_index, ctx) { backgroundColor: gradient, }, { - label: 'Visitors', + label: label, data: dashedPlot, borderWidth: 3, borderDash: [5, 10], @@ -37,7 +37,7 @@ function mainSet(plot, present_index, ctx) { }] } else { return [{ - label: 'Visitors', + label: label, data: plot, borderWidth: 3, borderColor: 'rgba(101,116,205)', @@ -89,7 +89,7 @@ function compareSet(plot, present_index, ctx) { } function dataSets(graphData, ctx) { - const dataSets = mainSet(graphData.plot, graphData.present_index, ctx) + const dataSets = mainSet(graphData.plot, graphData.present_index, ctx, graphData.interval === 'minute' ? 'Pageviews' : 'Visitors') if (graphData.compare_plot) { return dataSets.concat(compareSet(graphData.compare_plot, graphData.present_index, ctx)) @@ -105,13 +105,13 @@ const MONTHS = [ "November", "December" ] -function dateFormatter(graphData) { +function dateFormatter(interval, longForm) { return function(isoDate) { let date = new Date(isoDate) - if (graphData.interval === 'month') { + if (interval === 'month') { return MONTHS[date.getUTCMonth()]; - } else if (graphData.interval === 'date') { + } else if (interval === 'date') { return date.getUTCDate() + ' ' + MONTHS[date.getUTCMonth()]; } else if (graphData.interval === 'hour') { const parts = isoDate.split(/[^0-9]/); @@ -121,6 +121,13 @@ function dateFormatter(graphData) { hours = hours % 12; hours = hours ? hours : 12; // the hour '0' should be '12' return hours + ampm; + } else if (interval === 'minute') { + if (longForm) { + const minutesAgo = Math.abs(isoDate) + return minutesAgo === 1 ? '1 minute ago' : minutesAgo + ' minutes ago' + } else { + return isoDate + 'm' + } } } } @@ -128,13 +135,13 @@ function dateFormatter(graphData) { class LineGraph extends React.Component { componentDidMount() { const {graphData} = this.props - const ctx = document.getElementById("main-graph-canvas").getContext('2d'); + this.ctx = document.getElementById("main-graph-canvas").getContext('2d'); - this.chart = new Chart(ctx, { + this.chart = new Chart(this.ctx, { type: 'line', data: { labels: graphData.labels, - datasets: dataSets(graphData, ctx) + datasets: dataSets(graphData, this.ctx) }, options: { animation: false, @@ -160,7 +167,7 @@ class LineGraph extends React.Component { callbacks: { title: function(dataPoints) { const data = dataPoints[0] - return dateFormatter(graphData)(data.xLabel) + return dateFormatter(graphData.interval, true)(data.xLabel) }, beforeBody: function() { this.drawnLabels = {} @@ -201,7 +208,7 @@ class LineGraph extends React.Component { ticks: { autoSkip: true, maxTicksLimit: 8, - callback: dateFormatter(graphData), + callback: dateFormatter(graphData.interval), } }] } @@ -209,6 +216,18 @@ class LineGraph extends React.Component { }); } + componentDidUpdate(prevProps) { + if (this.props.graphData !== prevProps.graphData) { + const newDataset = dataSets(this.props.graphData, this.ctx) + + for (let i = 0; i < newDataset[0].data.length; i++) { + this.chart.data.datasets[0].data[i] = newDataset[0].data[i] + } + + this.chart.update() + } + } + onClick(e) { const query = new URLSearchParams(window.location.search) const element = this.chart.getElementsAtEventForMode(e, 'index', {intersect: false})[0] @@ -240,7 +259,7 @@ class LineGraph extends React.Component { renderTopStats() { const {graphData} = this.props - return this.props.graphData.top_stats.map((stat, index) => { + const stats = this.props.graphData.top_stats.map((stat, index) => { let border = index > 0 ? 'lg:border-l border-gray-300' : '' border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border @@ -254,6 +273,12 @@ class LineGraph extends React.Component {
) }) + + if (graphData.interval === 'minute') { + stats.push(
) + } + + return stats } downloadLink() { @@ -295,6 +320,7 @@ export default class VisitorGraph extends React.Component { componentDidMount() { this.fetchGraphData() + if (this.props.timer) this.props.timer.onTick(this.fetchGraphData.bind(this)) } componentDidUpdate(prevProps) { @@ -322,7 +348,7 @@ export default class VisitorGraph extends React.Component { render() { return ( -
+
{ this.state.loading &&
} { this.renderInner() } diff --git a/assets/package-lock.json b/assets/package-lock.json index 27636fe19..05384d2e2 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -6964,6 +6964,11 @@ "prop-types": "^15.5.10" } }, + "react-flip-move": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-flip-move/-/react-flip-move-3.0.4.tgz", + "integrity": "sha512-HyUVv9g3t/BS7Yz9HgrtYSWyRNdR2F81nkj+C5iRY675AwlqCLB5JU9mnZWg0cdVz7IM4iquoyZx70vzZv3Z8Q==" + }, "react-is": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", diff --git a/assets/package.json b/assets/package.json index 235fbf988..b2608ff93 100644 --- a/assets/package.json +++ b/assets/package.json @@ -26,6 +26,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-flatpickr": "^3.10.0", + "react-flip-move": "^3.0.4", "react-router-dom": "^5.1.2", "tailwindcss": "^1.3.1", "uglifyjs-webpack-plugin": "^2.2.0", diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index fd5c1e443..5a62203cb 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -155,8 +155,30 @@ defmodule Plausible.Stats.Clickhouse do {plot, compare_plot, labels, present_index} end + def calculate_plot(site, %Query{period: "realtime"}) do + groups = + Clickhouse.all( + from e in "events", + where: e.domain == ^site.domain, + where: e.timestamp >= fragment("now() - INTERVAL 31 MINUTE"), + select: + { + fragment("dateDiff('minute', now(), ?) as relativeMinute", e.timestamp), + fragment("count(*) as pageviews") + }, + group_by: fragment("relativeMinute"), + order_by: fragment("relativeMinute") + ) + |> Enum.map(fn row -> {row["relativeMinute"], row["pageviews"]} end) + |> Enum.into(%{}) + + labels = Enum.into(-30..-1, []) + plot = Enum.map(labels, fn label -> groups[label] || 0 end) + {plot, nil, labels, nil} + end + def bounce_rate(site, query) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) [res] = Clickhouse.all( @@ -169,6 +191,18 @@ defmodule Plausible.Stats.Clickhouse do res["bounce_rate"] || 0 end + def total_pageviews(site, %Query{period: "realtime"}) do + [res] = + Clickhouse.all( + from e in "events", + select: fragment("count(*) as pageviews"), + where: e.timestamp >= fragment("now() - INTERVAL 30 MINUTE"), + where: e.domain == ^site.domain + ) + + res["pageviews"] + end + def pageviews_and_visitors(site, query) do [res] = Clickhouse.all( @@ -213,7 +247,7 @@ defmodule Plausible.Stats.Clickhouse do end) end - def top_referrers(site, query, limit \\ 5, include \\ []) do + def top_referrers(site, query, limit, include) do referrers = Clickhouse.all( from e in base_session_query(site, query), @@ -241,7 +275,7 @@ defmodule Plausible.Stats.Clickhouse do end defp bounce_rates_by_referrer_source(site, query) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) Clickhouse.all( from s in "sessions", @@ -262,7 +296,7 @@ defmodule Plausible.Stats.Clickhouse do def visitors_from_referrer(site, query, referrer) do [res] = Clickhouse.all( - from e in base_query(site, query), + from e in base_session_query(site, query), select: fragment("uniq(user_id) as visitors"), where: e.referrer_source == ^referrer ) @@ -292,7 +326,7 @@ defmodule Plausible.Stats.Clickhouse do def referrer_drilldown(site, query, referrer, include \\ []) do referring_urls = Clickhouse.all( - from e in base_query(site, query), + from e in base_session_query(site, query), select: {fragment("? as name", e.referrer), fragment("uniq(user_id) as count")}, group_by: e.referrer, where: e.referrer_source == ^referrer, @@ -349,7 +383,7 @@ defmodule Plausible.Stats.Clickhouse do end defp bounce_rates_by_referring_url(site, query) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) Clickhouse.all( from s in "sessions", @@ -367,7 +401,17 @@ defmodule Plausible.Stats.Clickhouse do |> Enum.into(%{}) end - def top_pages(site, query, limit \\ 5, include \\ []) do + def top_pages(site, %Query{period: "realtime"} = query, limit, _include) do + Clickhouse.all( + from s in base_session_query(site, query), + select: {fragment("? as name", s.exit_page), fragment("uniq(?) as count", s.user_id)}, + group_by: s.exit_page, + order_by: [desc: fragment("count")], + limit: ^limit + ) + end + + def top_pages(site, query, limit, include) do pages = Clickhouse.all( from e in base_query(site, query), @@ -386,7 +430,7 @@ defmodule Plausible.Stats.Clickhouse do end defp bounce_rates_by_page_url(site, query) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) Clickhouse.all( from s in "sessions", @@ -520,6 +564,7 @@ defmodule Plausible.Stats.Clickhouse do def goal_conversions(site, query) do goals = Repo.all(from g in Plausible.Goal, where: g.domain == ^site.domain) + query = if query.period == "realtime", do: %Query{query | period: "30m"}, else: query (fetch_pageview_goals(goals, site, query) ++ fetch_event_goals(goals, site, query)) @@ -565,8 +610,17 @@ defmodule Plausible.Stats.Clickhouse do Enum.sort_by(conversions, fn conversion -> -conversion["count"] end) end + defp base_session_query(site, %Query{period: "realtime"}) do + first_datetime = Timex.now(site.timezone) |> Timex.shift(minutes: -5) |> Timex.Timezone.convert("UTC") + + from(s in "sessions", + where: s.domain == ^site.domain, + where: s.timestamp >= ^first_datetime + ) + end + defp base_session_query(site, query) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) from(s in "sessions", where: s.domain == ^site.domain, @@ -575,7 +629,7 @@ defmodule Plausible.Stats.Clickhouse do end defp base_query(site, query, events \\ ["pageview"]) do - {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + {first_datetime, last_datetime} = utc_boundaries(query, site.timezone) {goal_event, path} = event_name_for_goal(query) q = @@ -598,7 +652,19 @@ defmodule Plausible.Stats.Clickhouse do end end - defp date_range_utc_boundaries(date_range, timezone) do + defp utc_boundaries(%Query{period: "30m"}, timezone) do + last_datetime = NaiveDateTime.utc_now() |> Timex.to_datetime(timezone) |> Timex.Timezone.convert("UTC") + first_datetime = last_datetime |> Timex.shift(minutes: -30) + {first_datetime, last_datetime} + end + + defp utc_boundaries(%Query{period: "realtime"}, timezone) do + last_datetime = NaiveDateTime.utc_now() |> Timex.to_datetime(timezone) |> Timex.Timezone.convert("UTC") + first_datetime = last_datetime |> Timex.shift(minutes: -5) + {first_datetime, last_datetime} + end + + defp utc_boundaries(%Query{date_range: date_range}, timezone) do {:ok, first} = NaiveDateTime.new(date_range.first, ~T[00:00:00]) first_datetime = diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex index 018993693..a9d282d7f 100644 --- a/lib/plausible/stats/query.ex +++ b/lib/plausible/stats/query.ex @@ -13,6 +13,16 @@ defmodule Plausible.Stats.Query do Map.put(query, :date_range, Date.range(new_first, new_last)) end + def from(tz, %{"period" => "realtime"}) do + date = today(tz) + + %__MODULE__{ + period: "realtime", + step_type: "minute", + date_range: Date.range(date, date) + } + end + def from(_tz, %{"period" => "day", "date" => date} = params) do date = Date.from_iso8601!(date) diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 6e7ac3850..db3bf6afe 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -23,6 +23,19 @@ defmodule PlausibleWeb.Api.StatsController do }) end + defp fetch_top_stats(site, %Query{period: "realtime"} = query) do + [ + %{ + name: "Active visitors", + count: Stats.current_visitors(site), + }, + %{ + name: "Pageviews (last 30 min)", + count: Stats.total_pageviews(site, query), + } + ] + end + defp fetch_top_stats(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do prev_query = Query.shift_back(query) total_visitors = Stats.unique_visitors(site, %{query | filters: %{}}) diff --git a/lib/workers/send_email_report.ex b/lib/workers/send_email_report.ex index b3d4d658e..be76b0363 100644 --- a/lib/workers/send_email_report.ex +++ b/lib/workers/send_email_report.ex @@ -52,8 +52,8 @@ defmodule Plausible.Workers.SendEmailReport do bounce_rate = Stats.bounce_rate(site, query) prev_bounce_rate = Stats.bounce_rate(site, Query.shift_back(query)) change_bounce_rate = if prev_bounce_rate > 0, do: bounce_rate - prev_bounce_rate - referrers = Stats.top_referrers(site, query) - pages = Stats.top_pages(site, query) + referrers = Stats.top_referrers(site, query, 5, []) + pages = Stats.top_pages(site, query, 5, []) user = Plausible.Auth.find_user_by(email: email) login_link = user && Plausible.Sites.is_owner?(user.id, site) diff --git a/test/plausible/stats/query_test.exs b/test/plausible/stats/query_test.exs index 955c2e502..cddeab1a0 100644 --- a/test/plausible/stats/query_test.exs +++ b/test/plausible/stats/query_test.exs @@ -20,6 +20,14 @@ defmodule Plausible.Stats.QueryTest do assert q.step_type == "hour" end + test "parses realtime format" do + q = Query.from(@tz, %{"period" => "realtime"}) + + assert q.date_range.first == Timex.today() + assert q.date_range.last == Timex.today() + assert q.period == "realtime" + end + test "parses month format" do q = Query.from(@tz, %{"period" => "month", "date" => "2019-01-01"}) diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs index ddbd4f7bd..783bb934a 100644 --- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs @@ -5,6 +5,15 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do describe "GET /api/stats/main-graph - plot" do setup [:create_user, :log_in, :create_site] + test "displays pageviews for the last 30 minutes in realtime graph", %{conn: conn, site: site} do + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime") + + assert %{"plot" => plot} = json_response(conn, 200) + + assert Enum.count(plot) == 30 + assert Enum.any?(plot, fn pageviews -> pageviews > 0 end) + end + test "displays visitors for a day", %{conn: conn, site: site} do conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 5495f5df7..7fb7759b3 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -30,5 +30,13 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do %{"bounce_rate" => nil, "count" => 1, "name" => "/irrelevant"} ] end + + test "returns top pages in realtime report", %{conn: conn, site: site} do + conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime") + + assert json_response(conn, 200) == [ + %{"count" => 3, "name" => "/"} + ] + end end end diff --git a/test/plausible_web/controllers/api/stats_controller/referrers_test.exs b/test/plausible_web/controllers/api/stats_controller/referrers_test.exs index 11bcdd9bf..2d086aaa4 100644 --- a/test/plausible_web/controllers/api/stats_controller/referrers_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/referrers_test.exs @@ -31,6 +31,15 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do %{"name" => "Bing", "count" => 1, "bounce_rate" => 0, "url" => ""} ] end + + test "returns top referrer sources in realtime report", %{conn: conn, site: site} do + conn = get(conn, "/api/stats/#{site.domain}/referrers?period=realtime") + + assert json_response(conn, 200) == [ + %{"name" => "10words", "count" => 2, "url" => "10words.com"}, + %{"name" => "Bing", "count" => 1, "url" => ""} + ] + end end describe "GET /api/stats/:domain/goal/referrers" do @@ -74,8 +83,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do assert json_response(conn, 200) == %{ "total_visitors" => 2, "referrers" => [ - %{"name" => "10words.com/page2", "count" => 1}, - %{"name" => "10words.com/page1", "count" => 1} + %{"name" => "10words.com/page1", "count" => 2} ] } end @@ -90,8 +98,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do assert json_response(conn, 200) == %{ "total_visitors" => 2, "referrers" => [ - %{"name" => "10words.com/page2", "count" => 1, "bounce_rate" => nil}, - %{"name" => "10words.com/page1", "count" => 1, "bounce_rate" => 50.0} + %{"name" => "10words.com/page1", "count" => 2, "bounce_rate" => 50.0} ] } end diff --git a/test/support/clickhouse_setup.ex b/test/support/clickhouse_setup.ex index 6a1658fa3..794e71705 100644 --- a/test/support/clickhouse_setup.ex +++ b/test/support/clickhouse_setup.ex @@ -237,7 +237,54 @@ defmodule Plausible.Test.ClickhouseSetup do referrer: "", is_bounce: false, start: ~N[2019-01-01 03:00:00] - } + }, + %{ + domain: "test-site.com", + entry_page: "/", + exit_page: "/", + referrer_source: "Google", + referrer: "", + is_bounce: false, + start: ~N[2019-02-01 01:00:00], + timestamp: ~N[2019-02-01 01:00:00] + }, + %{ + domain: "test-site.com", + entry_page: "/", + exit_page: "/", + referrer_source: "Google", + referrer: "", + is_bounce: false, + start: ~N[2019-02-01 02:00:00], + timestamp: ~N[2019-02-01 02:00:00] + }, + %{ + domain: "test-site.com", + entry_page: "/", + exit_page: "/", + referrer: "t.co/some-link", + referrer_source: "Twitter", + start: ~N[2019-03-01 01:00:00], + timestamp: ~N[2019-03-01 01:00:00] + }, + %{ + domain: "test-site.com", + entry_page: "/", + exit_page: "/", + referrer: "t.co/some-link", + referrer_source: "Twitter", + start: ~N[2019-03-01 01:00:00], + timestamp: ~N[2019-03-01 01:00:00] + }, + %{ + domain: "test-site.com", + entry_page: "/", + exit_page: "/", + referrer: "t.co/nonexistent-link", + referrer_source: "Twitter", + start: ~N[2019-03-01 02:00:00], + timestamp: ~N[2019-03-01 02:00:00] + }, ]) end end