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