Skip to content

Commit

Permalink
Realtime dashboard (#212)
Browse files Browse the repository at this point in the history
* Auto-updating dashboard with realtime info

* Remove extra route

* Draw list of countries next to the map

* Nice animations

* Do not show bounce rates in realtime modals

* Update countries and devices in realtime

* Remove unused component

* Show total pageviews in the last 30 minutes

* Show proper labels

* Remove unnecessary z-index

* Fix label for main graph

* Fix compiler warnings

* Add tests

* Fix copy pluralizations

* Fix copy in countries modal

* Real-time -> Realtime

* Looser test assertion

* Show last 30 minutes conversions on realtime report

* Remove EventTarget API because it doesn't work on Safari

* Get referrer drilldown from sessions table

* Fix failing tests
  • Loading branch information
ukutaht authored Jul 14, 2020
1 parent 1c501db commit 232298d
Show file tree
Hide file tree
Showing 28 changed files with 492 additions and 143 deletions.
20 changes: 11 additions & 9 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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;
}

Expand All @@ -133,7 +132,10 @@ blockquote {
0% {
transform: scale(.33);
}
80%, 100% {
50% {
transform: scale(1);
}
40%, 100% {
opacity: 0;
}
}
Expand All @@ -142,10 +144,10 @@ blockquote {
0% {
transform: scale(.8);
}
50% {
25% {
transform: scale(1);
}
100% {
50%, 100% {
transform: scale(.8);
}
}
Expand Down
3 changes: 3 additions & 0 deletions assets/js/dashboard/datepicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
}
Expand Down Expand Up @@ -171,6 +173,7 @@ class DatePicker extends React.Component {
<div className="rounded bg-white shadow-xs font-medium text-gray-800">
<div className="py-1">
{ this.renderLink('day', 'Today') }
{ this.renderLink('realtime', 'Realtime') }
</div>
<div className="border-t border-gray-200"></div>
<div className="py-1">
Expand Down
48 changes: 48 additions & 0 deletions assets/js/dashboard/historical.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.props.query} />
</div>
)
}
}

render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
<CurrentVisitors timer={this.props.timer} site={this.props.site} />
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
<Filters query={this.props.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.props.query} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.props.query} />
<Pages site={this.props.site} query={this.props.query} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.props.query} />
<Devices site={this.props.site} query={this.props.query} />
</div>
{ this.renderConversions() }
</div>
)
}
}
77 changes: 33 additions & 44 deletions assets/js/dashboard/index.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -26,40 +42,13 @@ class Stats extends React.Component {
}
}

renderConversions() {
if (this.props.site.hasGoals) {
return (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.state.query} />
</div>
)
}
}

render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
<CurrentVisitors site={this.props.site} />
</div>
<Datepicker site={this.props.site} query={this.state.query} />
</div>
<Filters query={this.state.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.state.query} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.state.query} />
<Pages site={this.props.site} query={this.state.query} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.state.query} />
<Devices site={this.props.site} query={this.state.query} />
</div>
{ this.renderConversions() }
</div>
)
if (this.state.query.period === 'realtime') {
return <Realtime timer={this.state.timer} site={this.props.site} query={this.state.query} />
} else {
return <Historical timer={this.state.timer} site={this.props.site} query={this.state.query} />
}
}
}

export default withRouter(Stats)
export default withRouter(Dashboard)
4 changes: 2 additions & 2 deletions assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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)
let period = q.get('period')
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]
Expand Down
48 changes: 48 additions & 0 deletions assets/js/dashboard/realtime.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.props.query} title="Goal Conversions (last 30 min)" />
</div>
)
}
}

render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
<Filters query={this.props.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.props.query} timer={this.props.timer} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Pages site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Devices site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>

{ this.renderConversions() }
</div>
)
}
}
22 changes: 17 additions & 5 deletions assets/js/dashboard/stats/conversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span className="block px-2" style={{marginTop: '-26px'}}>{goalName}</span>
} else {
const query = new URLSearchParams(window.location.search)
query.set('goal', goalName)

return (
<Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
{ goalName }
</Link>
)
}
}

renderGoal(goal) {
return (
<div className="flex items-center justify-between my-2 text-sm" key={goal.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
<Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">{ goal.name }</Link>
{this.renderGoalText(goal.name)}
</div>
<span className="font-medium">{numberFormatter(goal.count)}</span>
</div>
Expand All @@ -53,7 +65,7 @@ export default class Conversions extends React.Component {
} else if (this.state.goals) {
return (
<div className="w-full bg-white shadow-xl rounded p-4">
<h3 className="font-bold">Goal Conversions</h3>
<h3 className="font-bold">{this.props.title || "Goal Conversions"}</h3>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Goal</span>
<span>Conversions</span>
Expand Down
Loading

0 comments on commit 232298d

Please sign in to comment.