mirror of
https://github.com/plausible/analytics.git
synced 2024-11-26 00:24:44 +03:00
Last updated tooltip (#2576)
* extract blinkingDot function * position pulsating-circle with tailwind instead * remove unused function * extract renderStatName function * display seconds since last realtime update Adds a 'Last updated X seconds ago' label to the Current Visitors tooltip. * small refactor: avoid duplication of this.props and this.state * show the 'last updated ...' tooltip in historical * changelog update * use className utility function
This commit is contained in:
parent
b6349df475
commit
3999f282a5
@ -3,7 +3,11 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 'Last updated X seconds ago' info to 'current visitors' tooltips
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Cascade delete sent_renewal_notifications table when user is deleted plausible/analytics#2549
|
||||||
- Show appropriate top-stat metric labels on the realtime dashboard when filtering by a goal
|
- Show appropriate top-stat metric labels on the realtime dashboard when filtering by a goal
|
||||||
- Fix breakdown API pagination when using event metrics plausible/analytics#2562
|
- Fix breakdown API pagination when using event metrics plausible/analytics#2562
|
||||||
- Automatically update all visible dashboard reports in the realtime view
|
- Automatically update all visible dashboard reports in the realtime view
|
||||||
@ -14,9 +18,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Always show direct traffic in sources reports plausible/analytics#2531
|
- Always show direct traffic in sources reports plausible/analytics#2531
|
||||||
- Stop recording XX and T1 country codes plausible/analytics#2556
|
- Stop recording XX and T1 country codes plausible/analytics#2556
|
||||||
|
|
||||||
## Fixed
|
|
||||||
- Cascade delete sent_renewal_notifications table when user is deleted plausible/analytics#2549
|
|
||||||
|
|
||||||
## v1.5.1 - 2022-12-06
|
## v1.5.1 - 2022-12-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -135,8 +135,6 @@ blockquote {
|
|||||||
|
|
||||||
.pulsating-circle {
|
.pulsating-circle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,8 @@ import Conversions from './stats/conversions'
|
|||||||
import { withPinnedHeader } from './pinned-header-hoc';
|
import { withPinnedHeader } from './pinned-header-hoc';
|
||||||
|
|
||||||
function Historical(props) {
|
function Historical(props) {
|
||||||
|
const tooltipBoundary = React.useRef(null)
|
||||||
|
|
||||||
function renderConversions() {
|
function renderConversions() {
|
||||||
if (props.site.hasGoals) {
|
if (props.site.hasGoals) {
|
||||||
return (
|
return (
|
||||||
@ -30,9 +32,9 @@ function Historical(props) {
|
|||||||
<div id="stats-container-top"></div>
|
<div id="stats-container-top"></div>
|
||||||
<div className={`relative top-0 sm:py-3 py-2 z-10 ${props.stuck && !props.site.embedded ? 'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
|
<div className={`relative top-0 sm:py-3 py-2 z-10 ${props.stuck && !props.site.embedded ? 'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
|
||||||
<div className="items-center w-full flex">
|
<div className="items-center w-full flex">
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full" ref={tooltipBoundary}>
|
||||||
<SiteSwitcher site={props.site} loggedIn={props.loggedIn} currentUserRole={props.currentUserRole} />
|
<SiteSwitcher site={props.site} loggedIn={props.loggedIn} currentUserRole={props.currentUserRole} />
|
||||||
<CurrentVisitors site={props.site} query={props.query} />
|
<CurrentVisitors site={props.site} query={props.query} lastLoadTimestamp={props.lastLoadTimestamp} tooltipBoundary={tooltipBoundary.current} />
|
||||||
<Filters className="flex" site={props.site} query={props.query} history={props.history} />
|
<Filters className="flex" site={props.site} query={props.query} history={props.history} />
|
||||||
</div>
|
</div>
|
||||||
<Datepicker site={props.site} query={props.query} />
|
<Datepicker site={props.site} query={props.query} />
|
||||||
|
@ -10,23 +10,37 @@ import { withComparisonProvider } from './comparison-provider-hoc';
|
|||||||
class Dashboard extends React.Component {
|
class Dashboard extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
this.updateLastLoadTimestamp = this.updateLastLoadTimestamp.bind(this)
|
||||||
this.state = {
|
this.state = {
|
||||||
query: parseQuery(props.location.search, this.props.site),
|
query: parseQuery(props.location.search, this.props.site),
|
||||||
|
lastLoadTimestamp: new Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.addEventListener('tick', this.updateLastLoadTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.location.search !== this.props.location.search) {
|
if (prevProps.location.search !== this.props.location.search) {
|
||||||
api.cancelAll()
|
api.cancelAll()
|
||||||
this.setState({query: parseQuery(this.props.location.search, this.props.site)})
|
this.setState({query: parseQuery(this.props.location.search, this.props.site)})
|
||||||
|
this.updateLastLoadTimestamp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLastLoadTimestamp() {
|
||||||
|
this.setState({lastLoadTimestamp: new Date()})
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { site, loggedIn, currentUserRole } = this.props
|
||||||
|
const { query, lastLoadTimestamp } = this.state
|
||||||
|
|
||||||
if (this.state.query.period === 'realtime') {
|
if (this.state.query.period === 'realtime') {
|
||||||
return <Realtime site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
return <Realtime site={site} loggedIn={loggedIn} currentUserRole={currentUserRole} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
|
||||||
} else {
|
} else {
|
||||||
return <Historical site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
return <Historical site={site} loggedIn={loggedIn} currentUserRole={currentUserRole} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ class Realtime extends React.Component {
|
|||||||
<Datepicker site={this.props.site} query={this.props.query} />
|
<Datepicker site={this.props.site} query={this.props.query} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorGraph site={this.props.site} query={this.props.query} />
|
<VisitorGraph site={this.props.site} query={this.props.query} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
||||||
<div className="items-start justify-between block w-full md:flex">
|
<div className="items-start justify-between block w-full md:flex">
|
||||||
<Sources site={this.props.site} query={this.props.query} />
|
<Sources site={this.props.site} query={this.props.query} />
|
||||||
<Pages site={this.props.site} query={this.props.query} />
|
<Pages site={this.props.site} query={this.props.query} />
|
||||||
|
@ -3,6 +3,8 @@ import { Link } from 'react-router-dom'
|
|||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
import * as url from '../util/url'
|
import * as url from '../util/url'
|
||||||
import { appliedFilters } from '../query';
|
import { appliedFilters } from '../query';
|
||||||
|
import { Tooltip } from '../util/tooltip';
|
||||||
|
import { SecondsSinceLastLoad } from '../util/seconds-since-last-load';
|
||||||
|
|
||||||
export default class CurrentVisitors extends React.Component {
|
export default class CurrentVisitors extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -25,18 +27,29 @@ export default class CurrentVisitors extends React.Component {
|
|||||||
.then((res) => this.setState({currentVisitors: res}))
|
.then((res) => this.setState({currentVisitors: res}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tooltipInfo() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="whitespace-nowrap text-small">Last updated <SecondsSinceLastLoad lastLoadTimestamp={this.props.lastLoadTimestamp} />s ago</p>
|
||||||
|
<p className="whitespace-nowrap font-normal text-xs">Click to view realtime dashboard</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (appliedFilters(this.props.query).length >= 1) { return null }
|
if (appliedFilters(this.props.query).length >= 1) { return null }
|
||||||
const { currentVisitors } = this.state;
|
const { currentVisitors } = this.state;
|
||||||
|
|
||||||
if (currentVisitors !== null) {
|
if (currentVisitors !== null) {
|
||||||
return (
|
return (
|
||||||
<Link to={url.setQuery('period', 'realtime')} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
|
<Tooltip info={this.tooltipInfo()} boundary={this.props.tooltipBoundary}>
|
||||||
<svg className="inline w-2 mr-1 md:mr-2 text-green-500 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
<Link to={url.setQuery('period', 'realtime')} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
|
||||||
<circle cx="8" cy="8" r="8" />
|
<svg className="inline w-2 mr-1 md:mr-2 text-green-500 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
</svg>
|
<circle cx="8" cy="8" r="8" />
|
||||||
{currentVisitors} <span className="hidden sm:inline-block">current visitor{currentVisitors === 1 ? '' : 's'}</span>
|
</svg>
|
||||||
</Link>
|
{currentVisitors} <span className="hidden sm:inline-block">current visitor{currentVisitors === 1 ? '' : 's'}</span>
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tooltip } from '../../util/tooltip'
|
import { Tooltip } from '../../util/tooltip'
|
||||||
|
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
||||||
import { METRIC_MAPPING } from './graph-util'
|
import { METRIC_MAPPING } from './graph-util'
|
||||||
@ -47,6 +48,7 @@ export default class TopStats extends React.Component {
|
|||||||
<div>
|
<div>
|
||||||
<div className="whitespace-nowrap">{this.topStatNumberLong(stat)} {statName}</div>
|
<div className="whitespace-nowrap">{this.topStatNumberLong(stat)} {statName}</div>
|
||||||
{this.canMetricBeGraphed(stat) && <div className="font-normal text-xs">{this.titleFor(stat)}</div>}
|
{this.canMetricBeGraphed(stat) && <div className="font-normal text-xs">{this.titleFor(stat)}</div>}
|
||||||
|
{stat.name === 'Current visitors' && <p className="font-normal text-xs">Last updated <SecondsSinceLastLoad lastLoadTimestamp={this.props.lastLoadTimestamp}/>s ago</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -76,21 +78,35 @@ export default class TopStats extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStat(stat) {
|
blinkingDot() {
|
||||||
return (
|
return (
|
||||||
<Tooltip info={this.topStatTooltip(stat)} className="flex items-center justify-between my-1 whitespace-nowrap">
|
<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>
|
||||||
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100">{this.topStatNumberShort(stat)}</b>
|
)
|
||||||
{this.renderComparison(stat.name, stat.change)}
|
}
|
||||||
</Tooltip>
|
|
||||||
|
renderStatName(stat) {
|
||||||
|
const { metric } = this.props
|
||||||
|
const isSelected = metric === METRIC_MAPPING[stat.name]
|
||||||
|
|
||||||
|
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
||||||
|
|
||||||
|
const statDisplayNameClass = classNames('text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b', {
|
||||||
|
'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500': isSelected,
|
||||||
|
'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent': !isSelected
|
||||||
|
})
|
||||||
|
|
||||||
|
return(
|
||||||
|
<div className={statDisplayNameClass}>
|
||||||
|
{statDisplayName}
|
||||||
|
{statExtraName && <span className="hidden sm:inline-block ml-1">{statExtraName}</span>}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { metric, topStatData, query } = this.props
|
const { topStatData, query } = this.props
|
||||||
|
|
||||||
const stats = topStatData && topStatData.top_stats.map((stat, index) => {
|
const stats = topStatData && topStatData.top_stats.map((stat, index) => {
|
||||||
const isSelected = metric === METRIC_MAPPING[stat.name]
|
|
||||||
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
|
||||||
|
|
||||||
const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
|
const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
|
||||||
'cursor-pointer': this.canMetricBeGraphed(stat),
|
'cursor-pointer': this.canMetricBeGraphed(stat),
|
||||||
@ -99,22 +115,18 @@ export default class TopStats extends React.Component {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={stat.name} info={this.topStatTooltip(stat)} className={className} onClick={() => { this.maybeUpdateMetric(stat) }} boundary={this.props.tooltipBoundary}>
|
<Tooltip key={stat.name} info={this.topStatTooltip(stat)} className={className} onClick={() => { this.maybeUpdateMetric(stat) }} boundary={this.props.tooltipBoundary}>
|
||||||
<div
|
{this.renderStatName(stat)}
|
||||||
className={`text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b ${isSelected ? 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500' : 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent'}`}>
|
<div className="flex items-center justify-between my-1 whitespace-nowrap">
|
||||||
{statDisplayName}
|
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100" id={METRIC_MAPPING[stat.name]}>{this.topStatNumberShort(stat)}</b>
|
||||||
{statExtraName && <span className="hidden sm:inline-block ml-1">{statExtraName}</span>}
|
{this.renderComparison(stat.name, stat.change)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between my-1 whitespace-nowrap">
|
</Tooltip>
|
||||||
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100" id={METRIC_MAPPING[stat.name]}>{this.topStatNumberShort(stat)}</b>
|
|
||||||
{this.renderComparison(stat.name, stat.change)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (stats && query && query.period === 'realtime') {
|
if (stats && query && query.period === 'realtime') {
|
||||||
stats.push(<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>)
|
stats.push(this.blinkingDot())
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats || null;
|
return stats || null;
|
||||||
|
@ -288,7 +288,7 @@ class LineGraph extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className="graph-inner">
|
<div className="graph-inner">
|
||||||
<div className="flex flex-wrap" ref={this.boundary}>
|
<div className="flex flex-wrap" ref={this.boundary}>
|
||||||
<TopStats query={query} metric={metric} updateMetric={updateMetric} topStatData={topStatData} tooltipBoundary={this.boundary.current}/>
|
<TopStats query={query} metric={metric} updateMetric={updateMetric} topStatData={topStatData} tooltipBoundary={this.boundary.current} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative px-2">
|
<div className="relative px-2">
|
||||||
<div className="absolute right-4 -top-10 py-2 md:py-0 flex items-center">
|
<div className="absolute right-4 -top-10 py-2 md:py-0 flex items-center">
|
||||||
@ -452,7 +452,7 @@ export default class VisitorGraph extends React.Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn show={showGraph}>
|
<FadeIn show={showGraph}>
|
||||||
<LineGraphWithRouter graphData={graphData} topStatData={topStatData} site={site} query={query} darkTheme={theme} metric={metric} updateMetric={this.updateMetric} updateInterval={this.updateInterval}/>
|
<LineGraphWithRouter graphData={graphData} topStatData={topStatData} site={site} query={query} darkTheme={theme} metric={metric} updateMetric={this.updateMetric} updateInterval={this.updateInterval} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
15
assets/js/dashboard/util/seconds-since-last-load.js
Normal file
15
assets/js/dashboard/util/seconds-since-last-load.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
// A function component that renders an integer value of how many
|
||||||
|
// seconds have passed from the last data load on the dashboard.
|
||||||
|
// Updates the value every second when the component is visible.
|
||||||
|
export function SecondsSinceLastLoad({ lastLoadTimestamp }) {
|
||||||
|
const [timeNow, setTimeNow] = useState(new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setTimeNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return Math.round(Math.abs(lastLoadTimestamp - timeNow) / 1000)
|
||||||
|
}
|
@ -23,7 +23,7 @@
|
|||||||
<h2 class="text-xl font-bold dark:text-gray-100">Waiting for first pageview</h2>
|
<h2 class="text-xl font-bold dark:text-gray-100">Waiting for first pageview</h2>
|
||||||
<h2 class="text-xl font-bold dark:text-gray-100">on <%= @site.domain %></h2>
|
<h2 class="text-xl font-bold dark:text-gray-100">on <%= @site.domain %></h2>
|
||||||
<div class="my-44">
|
<div class="my-44">
|
||||||
<div class="block pulsating-circle"></div>
|
<div class="block pulsating-circle top-1/2 left-1/2"></div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-xs absolute left-0 bottom-0 mb-6 w-full text-center leading-normal">
|
<p class="text-gray-600 dark:text-gray-400 text-xs absolute left-0 bottom-0 mb-6 w-full text-center leading-normal">
|
||||||
Need to see the snippet again? <%= link("Click here", to: "/#{URI.encode_www_form(@site.domain)}/snippet", class: "text-indigo-600 dark:text-indigo-500 text-underline")%><br />
|
Need to see the snippet again? <%= link("Click here", to: "/#{URI.encode_www_form(@site.domain)}/snippet", class: "text-indigo-600 dark:text-indigo-500 text-underline")%><br />
|
||||||
Not working? <%= link("Troubleshoot the integration", to: "https://plausible.io/docs/troubleshoot-integration#keep-seeing-a-blinking-green-dot", class: "text-indigo-600 dark:text-indigo-500 text-underline", rel: "noreferrer") %> with our guide
|
Not working? <%= link("Troubleshoot the integration", to: "https://plausible.io/docs/troubleshoot-integration#keep-seeing-a-blinking-green-dot", class: "text-indigo-600 dark:text-indigo-500 text-underline", rel: "noreferrer") %> with our guide
|
||||||
|
Loading…
Reference in New Issue
Block a user