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:
RobertJoonas 2023-01-16 10:30:22 +02:00 committed by GitHub
parent b6349df475
commit 3999f282a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 94 additions and 39 deletions

View File

@ -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

View File

@ -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;
} }

View File

@ -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} />

View File

@ -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}/>
} }
} }
} }

View File

@ -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} />

View File

@ -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>
) )
} }

View File

@ -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;

View File

@ -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>
) )
} }

View 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)
}

View File

@ -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