Dashboard: show comparison for breakdowns (#4692)

* Comparisons: Move code to LegacyQueryBuilder

* WIP: Return comparison results to frontend

* refactor: remove useless param

* Different result format

* Pass object to metric.renderValue

* remove dead code

* Fixup response format

* Comparison in a tooltip

Not perfect at all, but a good start. Problems arise with money etc.

* Simple change arrow

* Extract metric entry to ts

* popper attempt WIP

* Slightly nicer content

* Solve warning

* Unified changeArrow in app

* Remove needless spanning

* Always set `graph_metric` in top stats.

FE already has business logic around whether a given metric is graphable

* Remove dead code

* Move Money module under dashboard utils, keep in build

* change <Metric /> definition to take in a `formatter` and store default formatters in another (typed) const

* Use standard system for formatting numbers

* Arrows only in table

* remove dead import

* Inline renderValue

* Render metric name in tooltip

* numberFormatter -> numberShortFormatter

* numberShortFormatter update

* Separate long/short formatters

* Use long vs short formatters

* Put column name into tooltip

* Slightly improved label handling for percentages, conversion rate

* Improved boundary handling in tooltip.js

* Iterate tooltips, no tooltip for - revenue

* Update top stats tests after graph_metric change

* Change revenue metrics stats API return structure

Conversion now happens earlier in query pipeline, we return float for comparison purposes

* useQueryContext in a component

* graph_metric for current visitors to fix realtime view

* No tooltips if fully - row

* renderValue as a proper function

* Simplify MetricEntry

* Use common const

* tooltip to typescript

* More explicit return structure

* metric-entry -> metric-value

* Restore some files

* ChangeArrow

* Restore MoreLink

* Fix typing in MoreLink

* <MetricValue />

* Tests for MetricValue and ChangeArrow

* details modal fixups

* re-add space between arrow and percentage

* Solve stylelint issues

* Update test

* Format

* Add flag `breakdown_comparisons_ui`

* reformat

* Remove no change icon, better alignment

* Revert "Remove no change icon, better alignment"

This reverts commit a8d62b6383.

* number-formatter.ts

* numberLongFormatter refactor

* useMemo dependency

* Handle nulls/undefined in top stats

---------

Co-authored-by: Uku Taht <uku.taht@gmail.com>
This commit is contained in:
Karl-Aksel Puulmann 2024-10-22 12:02:13 +03:00 committed by GitHub
parent 0e4e614d4c
commit aec0318c3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1034 additions and 391 deletions

View File

@ -9,7 +9,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
/** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */
key: keyof T
/** Column title */
label: ReactNode
label: string
/** If defined, the column is considered sortable. @see SortButton */
onSort?: () => void
sortDirection?: SortDirection
@ -20,7 +20,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
/**
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/
renderValue?: (value: unknown) => ReactNode
renderValue?: (item: T) => ReactNode
/** Function used to create richer cells */
renderItem?: (item: T) => ReactNode
}
@ -85,7 +85,7 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
{renderItem
? renderItem(item)
: renderValue
? renderValue(item[key])
? renderValue(item)
: (item[key] ?? '')}
</TableCell>
))}

View File

@ -3,7 +3,7 @@ import FlipMove from 'react-flip-move';
import Chart from 'chart.js/auto';
import FunnelTooltip from './funnel-tooltip';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import numberFormatter from '../util/number-formatter';
import { numberShortFormatter } from '../util/number-formatter';
import Bar from '../stats/bar';
import RocketIcon from '../stats/modals/rocket-icon';
@ -103,7 +103,7 @@ export default function Funnel({ funnelName, tabs }) {
const formatDataLabel = (visitors, ctx) => {
if (ctx.dataset.label === 'Visitors') {
const conversionRate = funnel.steps[ctx.dataIndex].conversion_rate
return `${conversionRate}% \n(${numberFormatter(visitors)} Visitors)`
return `${conversionRate}% \n(${numberShortFormatter(visitors)} Visitors)`
} else {
return null
}
@ -330,7 +330,7 @@ export default function Funnel({ funnelName, tabs }) {
</Bar>
<span className="font-medium dark:text-gray-200 w-20 text-right" tooltip={step.visitors.toLocaleString()}>
{numberFormatter(step.visitors)}
{numberShortFormatter(step.visitors)}
</span>
</div>
</>

View File

@ -1,9 +0,0 @@
import React from 'react'
export default function Money({ formatted }) {
if (formatted) {
return <span tooltip={formatted.long}>{formatted.short}</span>
} else {
return "-"
}
}

View File

@ -45,7 +45,7 @@ const siteContextDefaultValue = {
embedded: false,
background: undefined as string | undefined,
isDbip: false,
flags: {},
flags: {} as { breakdown_comparisons_ui?: boolean },
validIntervalsByPeriod: {} as Record<string, Array<string>>,
shared: false
}

View File

@ -1,5 +1,6 @@
import { METRIC_FORMATTER, METRIC_LABELS } from './graph-util'
import dateFormatter from './date-formatter'
import { METRIC_LABELS } from './graph-util'
import { MetricFormatterShort } from '../reports/metric-formatter'
const renderBucketLabel = function(query, graphData, label, comparison = false) {
let isPeriodFull = graphData.full_intervals?.[label]
@ -44,7 +45,7 @@ const buildTooltipData = function(query, graphData, metric, tooltipModel) {
const comparisonValue = comparisonData?.raw || 0
const comparisonDifference = label && comparisonLabel && calculatePercentageDifference(comparisonValue, value)
const metricFormatter = METRIC_FORMATTER[metric]
const metricFormatter = MetricFormatterShort[metric]
const formattedValue = metricFormatter(value)
const formattedComparisonValue = comparisonData && metricFormatter(comparisonValue)

View File

@ -1,4 +1,3 @@
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters'
import { revenueAvailable } from '../../query'
@ -36,20 +35,6 @@ export const METRIC_LABELS = {
'total_revenue': 'Total Revenue',
}
export const METRIC_FORMATTER = {
'visitors': numberFormatter,
'pageviews': numberFormatter,
'events': numberFormatter,
'visits': numberFormatter,
'views_per_visit': (number) => (number),
'bounce_rate': (number) => (`${number}%`),
'visit_duration': durationFormatter,
'conversions': numberFormatter,
'conversion_rate': (number) => (`${number}%`),
'total_revenue': numberFormatter,
'average_revenue': numberFormatter,
}
const buildComparisonDataset = function(comparisonPlot) {
if (!comparisonPlot) return []

View File

@ -3,11 +3,12 @@ import { useAppNavigate } from '../../navigation/use-app-navigate';
import { useQueryContext } from '../../query-context';
import Chart from 'chart.js/auto';
import GraphTooltip from './graph-tooltip'
import { buildDataSet, METRIC_LABELS, METRIC_FORMATTER } from './graph-util'
import { buildDataSet, METRIC_LABELS } from './graph-util'
import dateFormatter from './date-formatter';
import FadeIn from '../../fade-in';
import classNames from 'classnames';
import { hasGoalFilter } from '../../util/filters';
import { MetricFormatterShort } from '../reports/metric-formatter'
const calculateMaximumY = function(dataset) {
const yAxisValues = dataset
@ -76,7 +77,7 @@ class LineGraph extends React.Component {
min: 0,
suggestedMax: calculateMaximumY(dataSet),
ticks: {
callback: METRIC_FORMATTER[metric],
callback: MetricFormatterShort[metric],
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
},
grid: {

View File

@ -4,13 +4,17 @@ import React from 'react'
import { Tooltip } from '../../util/tooltip'
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
import classNames from 'classnames'
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import * as storage from '../../util/storage'
import { formatDateRange } from '../../util/date'
import { getGraphableMetrics } from './graph-util'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { useLastLoadContext } from '../../last-load-context'
import { ChangeArrow } from '../reports/change-arrow'
import {
MetricFormatterShort,
MetricFormatterLong
} from '../reports/metric-formatter'
function Maybe({ condition, children }) {
if (condition) {
@ -20,67 +24,21 @@ function Maybe({ condition, children }) {
}
}
function renderPercentageComparison(name, comparison, forceDarkBg = false) {
const formattedComparison = numberFormatter(Math.abs(comparison))
const defaultClassName = classNames({
'pl-2 text-xs dark:text-gray-100': !forceDarkBg,
'pl-2 text-xs text-gray-100': forceDarkBg
})
const noChangeClassName = classNames({
'pl-2 text-xs text-gray-700 dark:text-gray-300': !forceDarkBg,
'pl-2 text-xs text-gray-300': forceDarkBg
})
if (comparison > 0) {
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
return (
<span className={defaultClassName}>
<span className={color + ' font-bold'}>&uarr;</span>{' '}
{formattedComparison}%
</span>
)
} else if (comparison < 0) {
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
return (
<span className={defaultClassName}>
<span className={color + ' font-bold'}>&darr;</span>{' '}
{formattedComparison}%
</span>
)
} else if (comparison === 0) {
return <span className={noChangeClassName}>&#12336; 0%</span>
function topStatNumberShort(metric, value) {
if (typeof value == 'number') {
const formatter = MetricFormatterShort[metric]
return formatter(value)
} else {
return null
}
}
function topStatNumberShort(name, value) {
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
} else if (
['average revenue', 'total revenue'].includes(name.toLowerCase())
) {
return value?.short
function topStatNumberLong(metric, value) {
if (typeof value == 'number') {
const formatter = MetricFormatterLong[metric]
return formatter(value)
} else {
return numberFormatter(value)
}
}
function topStatNumberLong(name, value) {
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
} else if (
['average revenue', 'total revenue'].includes(name.toLowerCase())
) {
return value?.long
} else {
return (value || 0).toLocaleString()
return null
}
}
@ -97,17 +55,20 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
<div>
{query.comparison && (
<div className="whitespace-nowrap">
{topStatNumberLong(stat.name, stat.value)} vs.{' '}
{topStatNumberLong(stat.name, stat.comparison_value)} {statName}
<span className="ml-2">
{renderPercentageComparison(stat.name, stat.change, true)}
</span>
{topStatNumberLong(stat.graph_metric, stat.value)} vs.{' '}
{topStatNumberLong(stat.graph_metric, stat.comparison_value)}{' '}
{statName}
<ChangeArrow
metric={stat.graph_metric}
change={stat.change}
className="pl-4 text-xs text-gray-100"
/>
</div>
)}
{!query.comparison && (
<div className="whitespace-nowrap">
{topStatNumberLong(stat.name, stat.value)} {statName}
{topStatNumberLong(stat.graph_metric, stat.value)} {statName}
</div>
)}
@ -123,7 +84,7 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
function canMetricBeGraphed(stat) {
const graphableMetrics = getGraphableMetrics(query, site)
return stat.graph_metric && graphableMetrics.includes(stat.graph_metric)
return graphableMetrics.includes(stat.graph_metric)
}
function maybeUpdateMetric(stat) {
@ -200,10 +161,14 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
className="font-bold text-xl dark:text-gray-100"
id={stat.graph_metric}
>
{topStatNumberShort(stat.name, stat.value)}
{topStatNumberShort(stat.graph_metric, stat.value)}
</p>
<Maybe condition={!query.comparison}>
{renderPercentageComparison(stat.name, stat.change)}
<Maybe condition={!query.comparison && stat.change != null}>
<ChangeArrow
metric={stat.graph_metric}
change={stat.change}
className="pl-2 text-xs dark:text-gray-100"
/>
</Maybe>
</span>
<Maybe condition={query.comparison}>
@ -216,7 +181,7 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
<Maybe condition={query.comparison}>
<div>
<p className="font-bold text-xl text-gray-500 dark:text-gray-400">
{topStatNumberShort(stat.name, stat.comparison_value)}
{topStatNumberShort(stat.graph_metric, stat.comparison_value)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatDateRange(site, data.comparing_from, data.comparing_to)}

View File

@ -5,7 +5,7 @@ import classNames from 'classnames'
import * as api from '../../api'
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
import { useAppNavigate } from '../../navigation/use-app-navigate'
import numberFormatter from '../../util/number-formatter'
import { numberShortFormatter } from '../../util/number-formatter'
import * as topojson from 'topojson-client'
import { useQuery } from '@tanstack/react-query'
import { useSiteContext } from '../../site-context'
@ -165,7 +165,7 @@ const WorldMap = ({
x={tooltip.x}
y={tooltip.y}
name={hoveredCountryData.name}
value={numberFormatter(hoveredCountryData.visitors)}
value={numberShortFormatter(hoveredCountryData.visitors)}
label={
labels[hoveredCountryData.visitors === 1 ? 'singular' : 'plural']
}

View File

@ -6,12 +6,9 @@ import Modal from './modal'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { usePaginatedGetAPI } from '../../hooks/api-client'
import { createVisitors, Metric } from '../reports/metrics'
import {
createVisitors,
Metric,
renderNumberWithTooltip
} from '../reports/metrics'
import numberFormatter, {
numberShortFormatter,
percentageFormatter
} from '../../util/number-formatter'
import { apiPath } from '../../util/url'
@ -33,21 +30,21 @@ const metrics = [
width: 'w-28',
key: 'impressions',
renderLabel: () => 'Impressions',
renderValue: renderNumberWithTooltip,
formatter: numberShortFormatter,
sortable: false
}),
new Metric({
width: 'w-16',
key: 'ctr',
renderLabel: () => 'CTR',
renderValue: percentageFormatter,
formatter: percentageFormatter,
sortable: false
}),
new Metric({
width: 'w-28',
key: 'position',
renderLabel: () => 'Position',
renderValue: numberFormatter,
formatter: numberShortFormatter,
sortable: false
})
]

View File

@ -0,0 +1,60 @@
/** @format */
import React from 'react'
import { render, screen } from '@testing-library/react'
import { ChangeArrow } from './change-arrow'
it('renders green for positive change', () => {
render(<ChangeArrow change={1} className="text-xs" metric="visitors" />)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('↑ 1%')
expect(arrowElement.children[0]).toHaveClass('text-green-500')
})
it('renders red for positive change', () => {
render(<ChangeArrow change={-10} className="text-xs" metric="visitors" />)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('↓ 10%')
expect(arrowElement.children[0]).toHaveClass('text-red-400')
})
it('renders tilde for no change', () => {
render(<ChangeArrow change={0} className="text-xs" metric="visitors" />)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('〰 0%')
})
it('inverts colors for positive bounce_rate change', () => {
render(<ChangeArrow change={15} className="text-xs" metric="bounce_rate" />)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('↑ 15%')
expect(arrowElement.children[0]).toHaveClass('text-red-400')
})
it('inverts colors for negative bounce_rate change', () => {
render(<ChangeArrow change={-3} className="text-xs" metric="bounce_rate" />)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('↓ 3%')
expect(arrowElement.children[0]).toHaveClass('text-green-500')
})
it('renders with text hidden', () => {
render(
<ChangeArrow change={-3} className="text-xs" metric="visitors" hideNumber />
)
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('↓')
expect(arrowElement.children[0]).toHaveClass('text-red-400')
})

View File

@ -0,0 +1,49 @@
/** @format */
import React from 'react'
import { Metric } from '../../../types/query-api'
import { numberShortFormatter } from '../../util/number-formatter'
export function ChangeArrow({
change,
metric,
className,
hideNumber
}: {
change: number
metric: Metric
className: string
hideNumber?: boolean
}) {
const formattedChange = hideNumber
? null
: ` ${numberShortFormatter(Math.abs(change))}%`
let content = null
if (change > 0) {
const color = metric === 'bounce_rate' ? 'text-red-400' : 'text-green-500'
content = (
<>
<span className={color + ' font-bold'}>&uarr;</span>
{formattedChange}
</>
)
} else if (change < 0) {
const color = metric === 'bounce_rate' ? 'text-green-500' : 'text-red-400'
content = (
<>
<span className={color + ' font-bold'}>&darr;</span>
{formattedChange}
</>
)
} else if (change === 0) {
content = <>&#12336;{formattedChange}</>
}
return (
<span className={className} data-testid="change-arrow">
{content}
</span>
)
}

View File

@ -1,26 +1,43 @@
import React, { useState, useEffect, useCallback } from 'react';
import { AppNavigationLink } from '../../navigation/use-app-navigate';
import FlipMove from 'react-flip-move';
/** @format */
import FadeIn from '../../fade-in';
import MoreLink from '../more-link';
import Bar from '../bar';
import LazyLoader from '../../components/lazy-loader';
import classNames from 'classnames';
import { trimURL } from '../../util/url';
import { cleanLabels, replaceFilterByPrefix, isRealTimeDashboard, hasGoalFilter, plainFilterText } from '../../util/filters';
import { useQueryContext } from '../../query-context';
import React, { useState, useEffect, useCallback } from 'react'
import { AppNavigationLink } from '../../navigation/use-app-navigate'
import FlipMove from 'react-flip-move'
import FadeIn from '../../fade-in'
import MoreLink from '../more-link'
import Bar from '../bar'
import LazyLoader from '../../components/lazy-loader'
import classNames from 'classnames'
import { trimURL } from '../../util/url'
import {
cleanLabels,
replaceFilterByPrefix,
isRealTimeDashboard,
hasGoalFilter,
plainFilterText
} from '../../util/filters'
import { useQueryContext } from '../../query-context'
const MAX_ITEMS = 9
export const MIN_HEIGHT = 380
const ROW_HEIGHT = 32
const ROW_GAP_HEIGHT = 4
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
const DATA_CONTAINER_HEIGHT =
(ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
const COL_MIN_WIDTH = 70
export function FilterLink({ path, filterInfo, onClick, children, extraClass }) {
const { query } = useQueryContext();
const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo })
export function FilterLink({
path,
filterInfo,
onClick,
children,
extraClass
}) {
const { query } = useQueryContext()
const className = classNames(`${extraClass}`, {
'hover:underline': !!filterInfo
})
if (filterInfo) {
const { prefix, filter, labels } = filterInfo
@ -33,7 +50,11 @@ export function FilterLink({ path, filterInfo, onClick, children, extraClass })
className={className}
path={path}
onClick={onClick}
search={(search) => ({...search, filters: newFilters, labels: newLabels})}
search={(search) => ({
...search,
filters: newFilters,
labels: newLabels
})}
>
{children}
</AppNavigationLink>
@ -53,7 +74,14 @@ function ExternalLink({ item, externalLinkDest }) {
href={dest}
className="w-4 h-4 hidden group-hover:block"
>
<svg className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
<svg
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path>
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path>
</svg>
</a>
)
}
@ -105,30 +133,44 @@ function ExternalLink({ item, externalLinkDest }) {
* | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ...
* | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ...
*/
export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WIDTH, afterFetchData, detailsLinkProps, maybeHideDetails, onClick, color, getFilterFor, renderIcon, externalLinkDest, fetchData }) {
const { query } = useQueryContext();
export default function ListReport({
keyLabel,
metrics,
colMinWidth = COL_MIN_WIDTH,
afterFetchData,
detailsLinkProps,
maybeHideDetails,
onClick,
color,
getFilterFor,
renderIcon,
externalLinkDest,
fetchData
}) {
const { query } = useQueryContext()
const [state, setState] = useState({ loading: true, list: null })
const [visible, setVisible] = useState(false)
const isRealtime = isRealTimeDashboard(query);
const goalFilterApplied = hasGoalFilter(query);
const isRealtime = isRealTimeDashboard(query)
const goalFilterApplied = hasGoalFilter(query)
const getData = useCallback(() => {
if (!isRealtime) {
setState({ loading: true, list: null })
}
fetchData()
.then((response) => {
if (afterFetchData) {
afterFetchData(response)
}
fetchData().then((response) => {
if (afterFetchData) {
afterFetchData(response)
}
setState({ loading: false, list: response.results })
})
// eslint-disable-next-line react-hooks/exhaustive-deps
setState({ loading: false, list: response.results })
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyLabel, query])
const onVisible = () => { setVisible(true) }
const onVisible = () => {
setVisible(true)
}
useEffect(() => {
if (isRealtime) {
@ -137,18 +179,22 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
// only read the new metrics once the new list is loaded.
setState({ loading: true, list: null })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [goalFilterApplied]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [goalFilterApplied])
useEffect(() => {
if (visible) {
if (isRealtime) { document.addEventListener('tick', getData) }
if (isRealtime) {
document.addEventListener('tick', getData)
}
getData()
}
return () => { document.removeEventListener('tick', getData) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyLabel, query, visible]);
return () => {
document.removeEventListener('tick', getData)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyLabel, query, visible])
// returns a filtered `metrics` list. Since currently, the backend can return different
// metrics based on filters and existing data, this function validates that the metrics
@ -171,9 +217,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
if (state.list && state.list.length > 0) {
return (
<div className="h-full flex flex-col">
<div style={{ height: ROW_HEIGHT }}>
{renderReportHeader()}
</div>
<div style={{ height: ROW_HEIGHT }}>{renderReportHeader()}</div>
<div style={{ minHeight: DATA_CONTAINER_HEIGHT }}>
{renderReportBody()}
@ -228,7 +272,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
function renderBarFor(listItem) {
const lightBackground = color || 'bg-green-50'
const metricToPlot = metrics.find(metric => metric.meta.plot).key
const metricToPlot = metrics.find((metric) => metric.meta.plot).key
return (
<div className="flex-grow w-full overflow-hidden">
@ -272,7 +316,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span className="font-medium text-sm dark:text-gray-200 text-right">
{metric.renderValue(listItem[metric.key])}
{metric.renderValue(listItem)}
</span>
</div>
)
@ -281,16 +325,26 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
function renderLoading() {
return (
<div className="w-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT}px` }}>
<div className="mx-auto loading"><div></div></div>
<div
className="w-full flex flex-col justify-center"
style={{ minHeight: `${MIN_HEIGHT}px` }}
>
<div className="mx-auto loading">
<div></div>
</div>
</div>
)
}
function renderNoDataYet() {
return (
<div className="w-full h-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT}px` }}>
<div className="mx-auto font-medium text-gray-500 dark:text-gray-400">No data yet</div>
<div
className="w-full h-full flex flex-col justify-center"
style={{ minHeight: `${MIN_HEIGHT}px` }}
>
<div className="mx-auto font-medium text-gray-500 dark:text-gray-400">
No data yet
</div>
</div>
)
}
@ -300,16 +354,26 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
const hideDetails = maybeHideDetails && !moreResultsAvailable
const showDetails = !!detailsLinkProps && !state.loading && !hideDetails
return showDetails && <MoreLink className={'mt-2'} linkProps={detailsLinkProps} list={state.list} />
return (
showDetails && (
<MoreLink
className={'mt-2'}
linkProps={detailsLinkProps}
list={state.list}
/>
)
)
}
return (
<LazyLoader onVisible={onVisible} >
<LazyLoader onVisible={onVisible}>
<div className="w-full" style={{ minHeight: `${MIN_HEIGHT}px` }}>
{state.loading && renderLoading()}
{!state.loading && <FadeIn show={!state.loading} className="h-full">
{renderReport()}
</FadeIn>}
{!state.loading && (
<FadeIn show={!state.loading} className="h-full">
{renderReport()}
</FadeIn>
)}
</div>
</LazyLoader>
)

View File

@ -0,0 +1,69 @@
/** @format */
import { Metric } from '../../../types/query-api'
import { formatMoneyShort, formatMoneyLong } from '../../util/money'
import {
numberShortFormatter,
durationFormatter,
percentageFormatter,
numberLongFormatter
} from '../../util/number-formatter'
export type FormattableMetric =
| Metric
| 'total_visitors'
| 'current_visitors'
| 'exit_rate'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ValueType = any
export const MetricFormatterShort: Record<
FormattableMetric,
(value: ValueType) => string
> = {
events: numberShortFormatter,
pageviews: numberShortFormatter,
total_visitors: numberShortFormatter,
current_visitors: numberShortFormatter,
views_per_visit: numberShortFormatter,
visitors: numberShortFormatter,
visits: numberShortFormatter,
time_on_page: durationFormatter,
visit_duration: durationFormatter,
bounce_rate: percentageFormatter,
conversion_rate: percentageFormatter,
exit_rate: percentageFormatter,
group_conversion_rate: percentageFormatter,
percentage: percentageFormatter,
average_revenue: formatMoneyShort,
total_revenue: formatMoneyShort
}
export const MetricFormatterLong: Record<
FormattableMetric,
(value: ValueType) => string
> = {
events: numberLongFormatter,
pageviews: numberLongFormatter,
total_visitors: numberLongFormatter,
current_visitors: numberShortFormatter,
views_per_visit: numberLongFormatter,
visitors: numberLongFormatter,
visits: numberLongFormatter,
time_on_page: durationFormatter,
visit_duration: durationFormatter,
bounce_rate: percentageFormatter,
conversion_rate: percentageFormatter,
exit_rate: percentageFormatter,
group_conversion_rate: percentageFormatter,
percentage: percentageFormatter,
average_revenue: formatMoneyLong,
total_revenue: formatMoneyLong
}

View File

@ -0,0 +1,212 @@
/** @format */
import React from 'react'
import {
render as libraryRender,
screen,
fireEvent,
waitFor
} from '@testing-library/react'
import MetricValue from './metric-value'
import SiteContextProvider, { PlausibleSite } from '../../site-context'
const REVENUE = { long: '$1,659.50', short: '$1.7K' }
describe('single value', () => {
it('renders small value', async () => {
await renderWithTooltip(<MetricValue {...valueProps('visitors', 10)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
})
it('renders large value', async () => {
await renderWithTooltip(<MetricValue {...valueProps('visitors', 12345)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('12.3k')
expect(screen.getByRole('tooltip')).toHaveTextContent('12,345')
})
it('renders percentages', async () => {
await renderWithTooltip(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
})
it('renders durations', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('visit_duration', 60)} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
})
it('renders with custom formatter', async () => {
await renderWithTooltip(
<MetricValue
{...valueProps('test_money', 5.3)}
formatter={(value) => `${value}$`}
/>
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
})
it('renders revenue properly', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('average_revenue', REVENUE)} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('$1.7K')
expect(screen.getByRole('tooltip')).toHaveTextContent('$1,659.50')
})
it('renders null revenue without tooltip', async () => {
render(<MetricValue {...valueProps('average_revenue', null)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('-')
await expect(waitForTooltip).rejects.toThrow()
})
})
describe('comparisons', () => {
it('renders increased metric', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('visitors', 10, { value: 5, change: 100 })} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10↑')
expect(screen.getByRole('tooltip')).toHaveTextContent(
'10 vs. 5 visitors↑ 100%'
)
})
it('renders decreased metric', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('visitors', 5, { value: 10, change: -50 })} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5↓')
expect(screen.getByRole('tooltip')).toHaveTextContent(
'5 vs. 10 visitors↓ 50%'
)
})
it('renders unchanged metric', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('visitors', 10, { value: 10, change: 0 })} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10〰')
expect(screen.getByRole('tooltip')).toHaveTextContent(
'10 vs. 10 visitors〰 0%'
)
})
it('renders metric with custom label', async () => {
await renderWithTooltip(
<MetricValue
{...valueProps('visitors', 10, { value: 10, change: 0 })}
renderLabel={() => 'Conversions'}
/>
)
expect(screen.getByRole('tooltip')).toHaveTextContent(
'10 vs. 10 conversions〰 0%'
)
})
it('does not render very short labels', async () => {
await renderWithTooltip(
<MetricValue
{...valueProps('percentage', 10, { value: 10, change: 0 })}
renderLabel={() => '%'}
/>
)
expect(screen.getByRole('tooltip')).toHaveTextContent('10% vs. 10%〰 0%')
})
it('renders with custom formatter', async () => {
await renderWithTooltip(
<MetricValue
{...valueProps('test', 10, { value: 5, change: 100 })}
formatter={(value) => `${value}$`}
/>
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10$↑')
expect(screen.getByRole('tooltip')).toHaveTextContent(
'10$ vs. 5$ test↑ 100%'
)
})
it('renders revenue change', async () => {
await renderWithTooltip(
<MetricValue
{...valueProps('average_revenue', REVENUE, {
value: REVENUE,
change: 0
})}
/>
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('$1.7K〰')
expect(screen.getByRole('tooltip')).toHaveTextContent(
'$1,659.50 vs. $1,659.50 average_revenue〰 0%'
)
})
it('renders without tooltip when revenue null', async () => {
render(
<MetricValue
{...valueProps('average_revenue', null, { value: null, change: 0 })}
/>
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('-')
await expect(waitForTooltip).rejects.toThrow()
})
})
function valueProps<T>(
metric: string,
value: T,
comparison?: { value: T; change: number }
) {
return {
metric: metric,
listItem: {
[metric]: value,
comparison: comparison && {
[metric]: comparison.value,
change: {
[metric]: comparison.change
}
}
},
renderLabel: (_query: unknown) => metric.toUpperCase()
} as any /* eslint-disable-line @typescript-eslint/no-explicit-any */
}
function render(ui: React.ReactNode) {
const site = {
flags: { breakdown_comparisons_ui: true }
} as unknown as PlausibleSite
libraryRender(<SiteContextProvider site={site}>{ui}</SiteContextProvider>)
}
async function renderWithTooltip(ui: React.ReactNode) {
render(ui)
await waitForTooltip()
}
async function waitForTooltip() {
fireEvent.mouseOver(screen.getByTestId('metric-value'))
await waitFor(() => screen.getByRole('tooltip'))
}

View File

@ -0,0 +1,125 @@
/** @format */
import React, { useMemo } from 'react'
import { Metric } from '../../../types/query-api'
import { Tooltip } from '../../util/tooltip'
import { ChangeArrow } from './change-arrow'
import {
MetricFormatterLong,
MetricFormatterShort,
ValueType
} from './metric-formatter'
import { DashboardQuery } from '../../query'
import { useQueryContext } from '../../query-context'
import { PlausibleSite, useSiteContext } from '../../site-context'
type MetricValues = Record<Metric, ValueType>
type ListItem = MetricValues & {
comparison: MetricValues & { change: Record<Metric, number> }
}
function valueRenderProps(
listItem: ListItem,
metric: Metric,
site: PlausibleSite
) {
const value = listItem[metric]
let comparison = null
if (site.flags.breakdown_comparisons_ui && listItem.comparison) {
comparison = {
value: listItem.comparison[metric],
change: listItem.comparison.change[metric]
}
}
return { value, comparison }
}
export default function MetricValue(props: {
listItem: ListItem
metric: Metric
renderLabel: (query: DashboardQuery) => string
formatter?: (value: ValueType) => string
}) {
const { query } = useQueryContext()
const site = useSiteContext()
const { metric, listItem } = props
const { value, comparison } = useMemo(
() => valueRenderProps(listItem, metric, site),
[listItem, metric, site]
)
const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
if (value === null && (!comparison || comparison.value === null)) {
return <span data-testid="metric-value">{shortFormatter(value)}</span>
}
return (
<Tooltip
info={
<ComparisonTooltipContent
value={value}
comparison={comparison}
metricLabel={metricLabel}
{...props}
/>
}
>
<span data-testid="metric-value">
{shortFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
metric={metric}
className="pl-2"
hideNumber
/>
) : null}
</span>
</Tooltip>
)
}
function ComparisonTooltipContent({
value,
comparison,
metric,
metricLabel,
formatter
}: {
value: ValueType
comparison: { value: ValueType; change: number } | null
metric: Metric
metricLabel: string
formatter?: (value: ValueType) => string
}) {
const longFormatter = formatter ?? MetricFormatterLong[metric]
const label = useMemo(() => {
if (metricLabel.length < 3) {
return ''
}
return ` ${metricLabel.toLowerCase()}`
}, [metricLabel])
if (comparison) {
return (
<div className="whitespace-nowrap">
{longFormatter(value)} vs. {longFormatter(comparison.value)}
{label}
<ChangeArrow
metric={metric}
change={comparison.change}
className="pl-4 text-xs text-gray-100"
/>
</div>
)
} else {
return <div className="whitespace-nowrap">{longFormatter(value)}</div>
}
}

View File

@ -1,19 +1,8 @@
import { hasGoalFilter } from "../../util/filters"
import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter"
import React from "react"
/** @format */
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
import React from 'react'
import MetricValue from './metric-value'
import { hasGoalFilter } from '../../util/filters'
// Class representation of a metric.
@ -25,11 +14,11 @@ const Money = maybeRequire().default
// * `key` - the key under which to read values under in an API
// * `renderValue` - a function that takes a value of this metric, and
// * `formatter` - a function that takes a value of this metric, and
// and returns the "rendered" version of it. Can be JSX or a string.
// * `renderLabel` - a function rendering a label for this metric given a
// query argument. Can return JSX or string.
// query argument. Returns string.
// ### Optional props
@ -39,21 +28,32 @@ const Money = maybeRequire().default
export class Metric {
constructor(props) {
if (!props.key) {
throw Error("Required field `key` is missing")
throw Error('Required field `key` is missing')
}
if (typeof props.renderLabel !== 'function') {
throw Error("Required field `renderLabel` should be a function")
}
if (typeof props.renderValue !== 'function') {
throw Error("Required field `renderValue` should be a function")
throw Error('Required field `renderLabel` should be a function')
}
this.key = props.key
this.renderValue = props.renderValue
this.renderLabel = props.renderLabel
this.meta = props.meta || {}
this.sortable = props.sortable
this.width = props.width ?? 'w-24'
this.formatter = props.formatter
this.renderLabel = props.renderLabel
this.renderValue = this.renderValue.bind(this)
}
renderValue(listItem) {
return (
<MetricValue
listItem={listItem}
metric={this.key}
renderLabel={this.renderLabel}
formatter={this.formatter}
/>
)
}
}
@ -65,16 +65,8 @@ export class Metric {
// * `realtimeLabel` - label when realtime period
// * `goalFilterLabel` - label when goal filter is applied
export const createVisitors = (props) => {
let renderValue
if (typeof props.renderValue === 'function') {
renderValue = props.renderValue
} else {
renderValue = renderNumberWithTooltip
}
let renderLabel
if (typeof props.renderLabel === 'function') {
renderLabel = props.renderLabel
} else {
@ -83,85 +75,139 @@ export const createVisitors = (props) => {
const realtimeLabel = props.realtimeLabel || 'Current visitors'
const goalFilterLabel = props.goalFilterLabel || 'Conversions'
if (query.period === 'realtime') { return realtimeLabel }
if (query && hasGoalFilter(query)) { return goalFilterLabel }
if (query.period === 'realtime') {
return realtimeLabel
}
if (query && hasGoalFilter(query)) {
return goalFilterLabel
}
return defaultLabel
}
}
return new Metric({width: 'w-24', sortable: true, ...props, key: "visitors", renderValue, renderLabel})
return new Metric({
width: 'w-24',
sortable: true,
...props,
key: 'visitors',
renderLabel
})
}
export const createConversionRate = (props) => {
const renderValue = percentageFormatter
const renderLabel = (_query) => "CR"
return new Metric({width: 'w-16', ...props, key: "conversion_rate", renderLabel, renderValue, sortable: true})
const renderLabel = (_query) => 'CR'
return new Metric({
width: 'w-16',
...props,
key: 'conversion_rate',
renderLabel,
sortable: true
})
}
export const createPercentage = (props) => {
const renderValue = (value) => value
const renderLabel = (_query) => "%"
return new Metric({width: 'w-16', ...props, key: "percentage", renderLabel, renderValue, sortable: true})
const renderLabel = (_query) => '%'
return new Metric({
width: 'w-16',
...props,
key: 'percentage',
renderLabel,
sortable: true
})
}
export const createEvents = (props) => {
const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip
return new Metric({width: 'w-24', ...props, key: "events", renderValue: renderValue, sortable: true})
return new Metric({ width: 'w-24', ...props, key: 'events', sortable: true })
}
export const createTotalRevenue = (props) => {
const renderValue = (value) => <Money formatted={value} />
const renderLabel = (_query) => "Revenue"
return new Metric({width: 'w-24', ...props, key: "total_revenue", renderValue, renderLabel, sortable: true})
const renderLabel = (_query) => 'Revenue'
return new Metric({
width: 'w-24',
...props,
key: 'total_revenue',
renderLabel,
sortable: true
})
}
export const createAverageRevenue = (props) => {
const renderValue = (value) => <Money formatted={value} />
const renderLabel = (_query) => "Average"
return new Metric({width: 'w-24', ...props, key: "average_revenue", renderValue, renderLabel, sortable: true})
const renderLabel = (_query) => 'Average'
return new Metric({
width: 'w-24',
...props,
key: 'average_revenue',
renderLabel,
sortable: true
})
}
export const createTotalVisitors = (props) => {
const renderValue = renderNumberWithTooltip
const renderLabel = (_query) => "Total Visitors"
return new Metric({width: 'w-28', ...props, key: "total_visitors", renderValue, renderLabel, sortable: false})
const renderLabel = (_query) => 'Total Visitors'
return new Metric({
width: 'w-28',
...props,
key: 'total_visitors',
renderLabel,
sortable: false
})
}
export const createVisits = (props) => {
const renderValue = renderNumberWithTooltip
return new Metric({width: 'w-24', sortable: true, ...props, key: "visits", renderValue })
return new Metric({ width: 'w-24', sortable: true, ...props, key: 'visits' })
}
export const createVisitDuration = (props) => {
const renderValue = durationFormatter
const renderLabel = (_query) => "Visit Duration"
return new Metric({width: 'w-36', ...props, key: "visit_duration", renderValue, renderLabel, sortable: true})
const renderLabel = (_query) => 'Visit Duration'
return new Metric({
width: 'w-36',
...props,
key: 'visit_duration',
renderLabel,
sortable: true
})
}
export const createBounceRate = (props) => {
const renderValue = (value) => `${value}%`
const renderLabel = (_query) => "Bounce Rate"
return new Metric({width: 'w-32', ...props, key: "bounce_rate", renderValue, renderLabel, sortable: true})
const renderLabel = (_query) => 'Bounce Rate'
return new Metric({
width: 'w-32',
...props,
key: 'bounce_rate',
renderLabel,
sortable: true
})
}
export const createPageviews = (props) => {
const renderValue = renderNumberWithTooltip
const renderLabel = (_query) => "Pageviews"
return new Metric({width: 'w-28', ...props, key: "pageviews", renderValue, renderLabel, sortable: true})
const renderLabel = (_query) => 'Pageviews'
return new Metric({
width: 'w-28',
...props,
key: 'pageviews',
renderLabel,
sortable: true
})
}
export const createTimeOnPage = (props) => {
const renderValue = durationFormatter
const renderLabel = (_query) => "Time on Page"
return new Metric({width: 'w-32', ...props, key: "time_on_page", renderValue, renderLabel, sortable: false})
const renderLabel = (_query) => 'Time on Page'
return new Metric({
width: 'w-32',
...props,
key: 'time_on_page',
renderLabel,
sortable: false
})
}
export const createExitRate = (props) => {
const renderValue = percentageFormatter
const renderLabel = (_query) => "Exit Rate"
return new Metric({width: 'w-28', ...props, key: "exit_rate", renderValue, renderLabel, sortable: false})
const renderLabel = (_query) => 'Exit Rate'
return new Metric({
width: 'w-28',
...props,
key: 'exit_rate',
renderLabel,
sortable: false
})
}
export function renderNumberWithTooltip(value) {
return <span tooltip={value}>{numberFormatter(value)}</span>
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import FadeIn from '../../fade-in'
import Bar from '../bar'
import MoreLink from '../more-link'
import numberFormatter from '../../util/number-formatter'
import { numberShortFormatter } from '../../util/number-formatter'
import RocketIcon from '../modals/rocket-icon'
import * as api from '../../api'
import LazyLoader from '../../components/lazy-loader'
@ -69,7 +69,7 @@ export default class SearchTerms extends React.Component {
</span>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(term.visitors)}</span>
<span className="font-medium dark:text-gray-200">{numberShortFormatter(term.visitors)}</span>
</div>
)
}

View File

@ -0,0 +1,17 @@
type Money = { long: string, short: string }
export function formatMoneyShort(value: Money | null) {
if (value) {
return value.short
} else {
return "-"
}
}
export function formatMoneyLong(value: Money | null) {
if (value) {
return value.long
} else {
return "-"
}
}

View File

@ -0,0 +1,33 @@
import { numberLongFormatter, numberShortFormatter } from "./number-formatter"
describe("numberShortFormatter()", () => {
it('converts to short format', () => {
expect(numberShortFormatter(0)).toEqual('0')
expect(numberShortFormatter(-10)).toEqual('-10')
expect(numberShortFormatter(12)).toEqual('12')
expect(numberShortFormatter(123)).toEqual('123')
expect(numberShortFormatter(1234)).toEqual('1.2k')
expect(numberShortFormatter(12345)).toEqual('12.3k')
expect(numberShortFormatter(123456)).toEqual('123k')
expect(numberShortFormatter(1234567)).toEqual('1.2M')
expect(numberShortFormatter(12345678)).toEqual('12.3M')
expect(numberShortFormatter(123456789)).toEqual('123M')
expect(numberShortFormatter(1234567890)).toEqual('1.2B')
})
})
describe("numberLongFormatter()", () => {
it('converts to short format', () => {
expect(numberLongFormatter(0)).toEqual('0')
expect(numberLongFormatter(-10)).toEqual('-10')
expect(numberLongFormatter(12)).toEqual('12')
expect(numberLongFormatter(123)).toEqual('123')
expect(numberLongFormatter(1234)).toEqual('1,234')
expect(numberLongFormatter(12345)).toEqual('12,345')
expect(numberLongFormatter(123456)).toEqual('123,456')
expect(numberLongFormatter(1234567)).toEqual('1,234,567')
expect(numberLongFormatter(12345678)).toEqual('12,345,678')
expect(numberLongFormatter(123456789)).toEqual('123,456,789')
expect(numberLongFormatter(1234567890)).toEqual('1,234,567,890')
})
})

View File

@ -6,7 +6,9 @@ const BILLION = 1000000000
const HUNDRED_BILLION = 100000000000
const TRILLION = 1000000000000
export default function numberFormatter(num) {
const numberFormat = Intl.NumberFormat("en-US")
export function numberShortFormatter(num: number): string {
if (num >= THOUSAND && num < MILLION) {
const thousands = num / THOUSAND
if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
@ -29,15 +31,19 @@ export default function numberFormatter(num) {
return (Math.floor(billions * 10) / 10) + 'B'
}
} else {
return num
return num.toString()
}
}
function pad(num, size) {
export function numberLongFormatter(num: number): string {
return numberFormat.format(num)
}
function pad(num: number, size: number): string {
return ('000' + num).slice(size * -1);
}
export function durationFormatter(duration) {
export function durationFormatter(duration: number): string {
const hours = Math.floor(duration / 60 / 60)
const minutes = Math.floor(duration / 60) % 60
const seconds = Math.floor(duration - (minutes * 60) - (hours * 60 * 60))
@ -50,7 +56,7 @@ export function durationFormatter(duration) {
}
}
export function percentageFormatter(number) {
export function percentageFormatter(number: number | null): string {
if (typeof (number) === 'number') {
return number + '%'
} else {

View File

@ -1,12 +1,19 @@
import React, { useState } from "react";
import React, { ReactNode, useState } from "react";
import { usePopper } from 'react-popper';
import classNames from 'classnames'
export function Tooltip({ children, info, className, onClick, boundary }) {
export function Tooltip({ children, info, className, onClick, boundary }: {
info: ReactNode,
children: ReactNode
className?: string,
onClick?: () => void,
boundary?: HTMLElement
}) {
const [visible, setVisible] = useState(false);
const [referenceElement, setReferenceElement] = useState(null);
const [popperElement, setPopperElement] = useState(null);
const [arrowElement, setArrowElement] = useState(null);
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null)
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top',
modifiers: [
@ -23,7 +30,7 @@ export function Tooltip({ children, info, className, onClick, boundary }) {
boundary: boundary,
},
},
],
].filter((x) => !!x),
});
return (

View File

@ -47,7 +47,13 @@ defmodule Plausible.Stats.Goal.Revenue do
get_goal_dimension_revenue_currency(query, dimension_values)
if currency do
Money.new!(value || 0, currency)
money = Money.new!(value || 0, currency)
%{
short: Money.to_string!(money, format: :short, fractional_digits: 1),
long: Money.to_string!(money),
value: Decimal.to_float(money.amount)
}
else
value
end

View File

@ -43,17 +43,31 @@ defmodule Plausible.Stats.Breakdown do
end
defp build_breakdown_result(query_result, query, metrics) do
dimension_keys = query.dimensions |> Enum.map(&result_key/1)
query_result.results
|> Enum.map(fn %{dimensions: dimensions, metrics: entry_metrics} ->
dimension_map =
query.dimensions |> Enum.map(&result_key/1) |> Enum.zip(dimensions) |> Enum.into(%{})
|> Enum.map(fn entry ->
comparison_map =
if entry[:comparison] do
comparison =
build_map(metrics, entry.comparison.metrics)
|> Map.put(:change, build_map(metrics, entry.comparison.change))
metrics_map = Enum.zip(metrics, entry_metrics) |> Enum.into(%{})
%{comparison: comparison}
else
%{}
end
Map.merge(dimension_map, metrics_map)
build_map(dimension_keys, entry.dimensions)
|> Map.merge(build_map(metrics, entry.metrics))
|> Map.merge(comparison_map)
end)
end
defp build_map(keys, values) do
Enum.zip(keys, values) |> Map.new()
end
defp result_key("event:props:" <> custom_property), do: custom_property
defp result_key("event:" <> key), do: key |> String.to_existing_atom()
defp result_key("visit:" <> key), do: key |> String.to_existing_atom()

View File

@ -14,13 +14,7 @@ defmodule Plausible.Stats.Compare do
def percent_change(nil, _new_count), do: nil
def percent_change(_old_count, nil), do: nil
def percent_change(%Money{} = old_count, %Money{} = new_count) do
percent_change(old_count |> Money.to_decimal(), new_count |> Money.to_decimal())
end
def percent_change(%Decimal{} = old_count, %Decimal{} = new_count) do
old_count = old_count |> Decimal.to_float()
new_count = new_count |> Decimal.to_float()
def percent_change(%{value: old_count}, %{value: new_count}) do
percent_change(old_count, new_count)
end

View File

@ -21,6 +21,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
|> put_parsed_filters(params)
|> put_preloaded_goals(site)
|> put_order_by(params)
|> put_include_comparisons(site, params)
|> Query.put_experimental_reduced_joins(site, params)
|> Query.put_imported_opts(site, params)
@ -196,6 +197,11 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
end
end
defp put_include_comparisons(query, site, params) do
comparisons = parse_comparison_params(site, params)
struct!(query, include: Map.put(query.include, :comparisons, comparisons))
end
@doc """
### Examples:
iex> QueryBuilder.parse_order_by(nil)
@ -276,4 +282,31 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
_ -> today(query)
end
end
def parse_comparison_params(_site, %{"period" => period}) when period in ~w(realtime all),
do: nil
def parse_comparison_params(_site, %{"comparison" => mode} = params)
when mode in ["previous_period", "year_over_year"] do
%{
mode: mode,
match_day_of_week: params["match_day_of_week"] == "true"
}
end
def parse_comparison_params(site, %{"comparison" => "custom"} = params) do
{:ok, date_range} =
Filters.QueryParser.parse_date_range_pair(site, [
params["compare_from"],
params["compare_to"]
])
%{
mode: "custom",
date_range: date_range,
match_day_of_week: params["match_day_of_week"] == "true"
}
end
def parse_comparison_params(_site, _options), do: nil
end

View File

@ -105,9 +105,7 @@ defmodule PlausibleWeb.Api.StatsController do
:ok <- validate_interval(params),
:ok <- validate_interval_granularity(site, params, dates),
params <- realtime_period_to_30m(params),
query =
Query.from(site, params, debug_metadata(conn))
|> Query.set_include(:comparisons, parse_comparison_options(site, params)),
query = Query.from(site, params, debug_metadata(conn)),
{:ok, metric} <- parse_and_validate_graph_metric(params, query) do
{timeseries_result, comparison_result, _meta} = Stats.timeseries(site, query, [metric])
@ -133,7 +131,7 @@ defmodule PlausibleWeb.Api.StatsController do
Enum.map(timeseries, fn row ->
case row[metric] do
nil -> 0
%Money{} = money -> Decimal.to_float(money.amount)
%{value: value} -> value
value -> value
end
end)
@ -198,12 +196,10 @@ defmodule PlausibleWeb.Api.StatsController do
params = realtime_period_to_30m(params)
query =
Query.from(site, params, debug_metadata(conn))
|> Query.set_include(:comparisons, parse_comparison_options(site, params))
query = Query.from(site, params, debug_metadata(conn))
{top_stats, sample_percent} = fetch_top_stats(site, query)
comparison_query = comparison_query(site, query, params)
comparison_query = comparison_query(query)
json(conn, %{
top_stats: top_stats,
@ -212,8 +208,8 @@ defmodule PlausibleWeb.Api.StatsController do
with_imported_switch: with_imported_switch_info(query, comparison_query),
includes_imported: includes_imported?(query, comparison_query),
imports_exist: site.complete_import_ids != [],
comparing_from: comparison_query && Query.date_range(comparison_query).first,
comparing_to: comparison_query && Query.date_range(comparison_query).last,
comparing_from: query.include.comparisons && Query.date_range(comparison_query).first,
comparing_to: query.include.comparisons && Query.date_range(comparison_query).last,
from: Query.date_range(query).first,
to: Query.date_range(query).last
})
@ -326,6 +322,7 @@ defmodule PlausibleWeb.Api.StatsController do
stats = [
%{
name: "Current visitors",
graph_metric: :current_visitors,
value: Stats.current_visitors(site)
},
%{
@ -354,6 +351,7 @@ defmodule PlausibleWeb.Api.StatsController do
stats = [
%{
name: "Current visitors",
graph_metric: :current_visitors,
value: Stats.current_visitors(site)
},
%{
@ -379,21 +377,15 @@ defmodule PlausibleWeb.Api.StatsController do
[
top_stats_entry(results, "Unique visitors", :total_visitors),
top_stats_entry(results, "Unique conversions", :visitors, graphable?: true),
top_stats_entry(results, "Total conversions", :events, graphable?: true),
top_stats_entry(results, "Unique conversions", :visitors),
top_stats_entry(results, "Total conversions", :events),
on_ee do
top_stats_entry(results, "Average revenue", :average_revenue,
formatter: &format_money/1,
graphable?: true
)
top_stats_entry(results, "Average revenue", :average_revenue)
end,
on_ee do
top_stats_entry(results, "Total revenue", :total_revenue,
formatter: &format_money/1,
graphable?: true
)
top_stats_entry(results, "Total revenue", :total_revenue)
end,
top_stats_entry(results, "Conversion rate", :conversion_rate, graphable?: true)
top_stats_entry(results, "Conversion rate", :conversion_rate)
]
|> Enum.reject(&is_nil/1)
|> then(&{&1, 100})
@ -415,12 +407,12 @@ defmodule PlausibleWeb.Api.StatsController do
stats =
[
top_stats_entry(current_results, "Unique visitors", :visitors, graphable?: true),
top_stats_entry(current_results, "Total visits", :visits, graphable?: true),
top_stats_entry(current_results, "Total pageviews", :pageviews, graphable?: true),
top_stats_entry(current_results, "Views per visit", :views_per_visit, graphable?: true),
top_stats_entry(current_results, "Bounce rate", :bounce_rate, graphable?: true),
top_stats_entry(current_results, "Visit duration", :visit_duration, graphable?: true),
top_stats_entry(current_results, "Unique visitors", :visitors),
top_stats_entry(current_results, "Total visits", :visits),
top_stats_entry(current_results, "Total pageviews", :pageviews),
top_stats_entry(current_results, "Views per visit", :views_per_visit),
top_stats_entry(current_results, "Bounce rate", :bounce_rate),
top_stats_entry(current_results, "Visit duration", :visit_duration),
top_stats_entry(current_results, "Time on page", :time_on_page,
formatter: fn
nil -> 0
@ -438,20 +430,11 @@ defmodule PlausibleWeb.Api.StatsController do
formatter = Keyword.get(opts, :formatter, & &1)
value = get_in(current_results, [key, :value])
%{name: name, value: formatter.(value)}
|> maybe_put_graph_metric(opts, key)
%{name: name, value: formatter.(value), graph_metric: key}
|> maybe_put_comparison(current_results, key, formatter)
end
end
defp maybe_put_graph_metric(entry, opts, key) do
if Keyword.get(opts, :graphable?) do
entry |> Map.put(:graph_metric, key)
else
entry
end
end
defp maybe_put_comparison(entry, results, key, formatter) do
prev_value = get_in(results, [key, :comparison_value])
change = get_in(results, [key, :change])
@ -1274,11 +1257,6 @@ defmodule PlausibleWeb.Api.StatsController do
site
|> Stats.breakdown(query, metrics, pagination)
|> transform_keys(%{goal: :name})
|> Enum.map(fn goal ->
goal
|> Enum.map(&format_revenue_metric/1)
|> Map.new()
end)
if params["csv"] do
to_csv(conversions, [:name, :visitors, :events], [
@ -1361,10 +1339,6 @@ defmodule PlausibleWeb.Api.StatsController do
props =
Stats.breakdown(site, query, metrics, pagination)
|> transform_keys(%{prop_key => :name})
|> Enum.map(fn entry ->
Enum.map(entry, &format_revenue_metric/1)
|> Map.new()
end)
%{results: props, skip_imported_reason: query.skip_imported_reason}
end
@ -1529,41 +1503,12 @@ defmodule PlausibleWeb.Api.StatsController do
|> halt()
end
def comparison_query(site, query, params) do
options = parse_comparison_options(site, params)
if options do
Comparisons.get_comparison_query(query, options)
def comparison_query(query) do
if query.include.comparisons do
Comparisons.get_comparison_query(query, query.include.comparisons)
end
end
def parse_comparison_options(_site, %{"period" => period}) when period in ~w(realtime all),
do: nil
def parse_comparison_options(_site, %{"comparison" => mode} = params)
when mode in ["previous_period", "year_over_year"] do
%{
mode: mode,
match_day_of_week: params["match_day_of_week"] == "true"
}
end
def parse_comparison_options(site, %{"comparison" => "custom"} = params) do
{:ok, date_range} =
Filters.QueryParser.parse_date_range_pair(site, [
params["compare_from"],
params["compare_to"]
])
%{
mode: "custom",
date_range: date_range,
match_day_of_week: params["match_day_of_week"] == "true"
}
end
def parse_comparison_options(_site, _options), do: nil
defp includes_imported?(source_query, comparison_query) do
cond do
source_query.include_imported -> true
@ -1572,15 +1517,6 @@ defmodule PlausibleWeb.Api.StatsController do
end
end
on_ee do
defdelegate format_revenue_metric(metric_value), to: PlausibleWeb.Controllers.API.Revenue
defdelegate format_money(money), to: PlausibleWeb.Controllers.API.Revenue
else
defp format_revenue_metric({metric, value}) do
{metric, value}
end
end
defp breakdown_metrics(query, extra_metrics \\ []) do
if Filters.filtering_on_dimension?(query, "event:goal") do
[:visitors, :conversion_rate, :total_visitors]

View File

@ -367,7 +367,10 @@ defmodule PlausibleWeb.StatsController do
defp get_flags(user, site),
do: %{
channels:
FunWithFlags.enabled?(:channels, for: user) || FunWithFlags.enabled?(:channels, for: site)
FunWithFlags.enabled?(:channels, for: user) || FunWithFlags.enabled?(:channels, for: site),
breakdown_comparisons_ui:
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: user) ||
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: site)
}
defp is_dbip() do

View File

@ -304,8 +304,16 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
"visitors" => 5,
"events" => 5,
"conversion_rate" => 100.0,
"average_revenue" => %{"short" => "€166.7M", "long" => "€166,733,566.75"},
"total_revenue" => %{"short" => "€500.2M", "long" => "€500,200,700.25"}
"average_revenue" => %{
"short" => "€166.7M",
"long" => "€166,733,566.75",
"value" => 166_733_566.748
},
"total_revenue" => %{
"short" => "€500.2M",
"long" => "€500,200,700.25",
"value" => 500_200_700.246
}
}
]
end
@ -380,11 +388,11 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
assert [
%{
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0", "value" => 10.0},
"conversion_rate" => 16.7,
"name" => "Payment",
"events" => 1,
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0", "value" => 10.0},
"visitors" => 1
},
%{

View File

@ -796,16 +796,16 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"name" => "true",
"events" => 2,
"conversion_rate" => 66.7,
"total_revenue" => %{"long" => "€112.00", "short" => "€112.0"},
"average_revenue" => %{"long" => "€56.00", "short" => "€56.0"}
"total_revenue" => %{"long" => "€112.00", "short" => "€112.0", "value" => 112.00},
"average_revenue" => %{"long" => "€56.00", "short" => "€56.0", "value" => 56.00}
},
%{
"visitors" => 1,
"name" => "false",
"events" => 1,
"conversion_rate" => 33.3,
"total_revenue" => %{"long" => "€8.00", "short" => "€8.0"},
"average_revenue" => %{"long" => "€8.00", "short" => "€8.0"}
"total_revenue" => %{"long" => "€8.00", "short" => "€8.0", "value" => 8.00},
"average_revenue" => %{"long" => "€8.00", "short" => "€8.0", "value" => 8.00}
}
]
end
@ -857,16 +857,16 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"name" => "true",
"events" => 2,
"conversion_rate" => 66.7,
"total_revenue" => %{"long" => "€80.00", "short" => "€80.0"},
"average_revenue" => %{"long" => "€40.00", "short" => "€40.0"}
"total_revenue" => %{"long" => "€80.00", "short" => "€80.0", "value" => 80.0},
"average_revenue" => %{"long" => "€40.00", "short" => "€40.0", "value" => 40.0}
},
%{
"visitors" => 1,
"name" => "false",
"events" => 1,
"conversion_rate" => 33.3,
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0"}
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0", "value" => 10.0},
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0", "value" => 10.0}
}
]
end

View File

@ -21,7 +21,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{"graph_metric" => "visit_duration"} = visit_duration
end
test "returns graph_metric key for graphable top stats in realtime mode", %{
test "returns graph_metric key for top stats in realtime mode", %{
conn: conn,
site: site
} do
@ -31,7 +31,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|> json_response(200)
|> Map.get("top_stats")
refute Map.has_key?(current_visitors, "graph_metric")
assert %{"graph_metric" => "current_visitors"} = current_visitors
assert %{"graph_metric" => "visitors"} = unique_visitors
assert %{"graph_metric" => "pageviews"} = pageviews
end
@ -189,7 +189,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Time on page", "value" => 900} in res["top_stats"]
assert %{"name" => "Time on page", "value" => 900, "graph_metric" => "time_on_page"} in res[
"top_stats"
]
end
test "calculates time on page when filtered for multiple pages", %{
@ -228,7 +230,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Time on page", "value" => 480} in res["top_stats"]
assert %{"name" => "Time on page", "value" => 480, "graph_metric" => "time_on_page"} in res[
"top_stats"
]
end
test "calculates time on page when filtered for multiple negated pages", %{
@ -267,7 +271,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Time on page", "value" => 60} in res["top_stats"]
assert %{"name" => "Time on page", "value" => 60, "graph_metric" => "time_on_page"} in res[
"top_stats"
]
end
test "calculates time on page when filtered for multiple wildcard pages", %{
@ -307,7 +313,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Time on page", "value" => 480} in res["top_stats"]
assert %{"name" => "Time on page", "value" => 480, "graph_metric" => "time_on_page"} in res[
"top_stats"
]
end
test "calculates time on page when filtered for multiple negated wildcard pages", %{
@ -349,7 +357,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Time on page", "value" => 600} in res["top_stats"]
assert %{"name" => "Time on page", "value" => 600, "graph_metric" => "time_on_page"} in res[
"top_stats"
]
end
test "doesn't calculate time on page with only single page visits", %{conn: conn, site: site} do
@ -361,7 +371,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
filters = Jason.encode!(%{page: "/"})
path = "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
assert %{"name" => "Time on page", "value" => 0} ==
assert %{"name" => "Time on page", "value" => 0, "graph_metric" => "time_on_page"} ==
conn
|> get(path)
|> json_response(200)
@ -397,7 +407,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
filters = Jason.encode!(%{page: "/"})
path = "/api/stats/#{site.domain}/top-stats?&filters=#{filters}"
assert %{"name" => "Time on page", "value" => 0} ==
assert %{"name" => "Time on page", "value" => 0, "graph_metric" => "time_on_page"} ==
conn
|> get(path)
|> json_response(200)
@ -416,7 +426,11 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
filters = Jason.encode!(%{page: "/"})
path = "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
assert %{"name" => "Time on page", "value" => _three_minutes = 180} ==
assert %{
"name" => "Time on page",
"value" => _three_minutes = 180,
"graph_metric" => "time_on_page"
} ==
conn
|> get(path)
|> json_response(200)
@ -453,7 +467,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
filters = Jason.encode!(%{page: "/a"})
path = "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
assert %{"name" => "Time on page", "value" => 100} ==
assert %{"name" => "Time on page", "value" => 100, "graph_metric" => "time_on_page"} ==
conn
|> get(path)
|> json_response(200)
@ -775,7 +789,10 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
res = json_response(conn, 200)
assert %{"name" => "Current visitors", "value" => 2} in res["top_stats"]
assert %{"name" => "Current visitors", "value" => 2, "graph_metric" => "current_visitors"} in res[
"top_stats"
]
end
test "shows unique visitors (last 30 minutes)", %{conn: conn, site: site} do
@ -826,7 +843,10 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime&filters=#{filters}")
res = json_response(conn, 200)
assert %{"name" => "Current visitors", "value" => 3} in res["top_stats"]
assert %{"name" => "Current visitors", "value" => 3, "graph_metric" => "current_visitors"} in res[
"top_stats"
]
end
test "shows unique/total conversions (last 30 min) with goal filter", %{
@ -865,7 +885,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
describe "GET /api/stats/top-stats - filters" do
setup [:create_user, :log_in, :create_new_site]
test "returns graph_metric key for graphable top stats with a page filter", %{
test "returns graph_metric key for top stats with a page filter", %{
conn: conn,
site: site
} do
@ -881,11 +901,10 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{"graph_metric" => "visits"} = visits
assert %{"graph_metric" => "pageviews"} = pageviews
assert %{"graph_metric" => "bounce_rate"} = bounce_rate
refute Map.has_key?(time_on_page, "graph_metric")
assert %{"graph_metric" => "time_on_page"} = time_on_page
end
test "returns graph_metric key for graphable top stats with a goal filter", %{
test "returns graph_metric key for top stats with a goal filter", %{
conn: conn,
site: site
} do
@ -897,13 +916,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|> json_response(200)
|> Map.get("top_stats")
refute Map.has_key?(unique_visitors, "graph_metric")
assert %{"graph_metric" => "total_visitors"} = unique_visitors
assert %{"graph_metric" => "visitors"} = unique_conversions
assert %{"graph_metric" => "events"} = total_conversions
assert %{"graph_metric" => "conversion_rate"} = cr
end
test "returns graph_metric key for graphable top stats with a goal filter in realtime mode",
test "returns graph_metric key for top stats with a goal filter in realtime mode",
%{conn: conn, site: site} do
filters = Jason.encode!(%{goal: "Signup"})
@ -913,7 +932,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|> json_response(200)
|> Map.get("top_stats")
refute Map.has_key?(current_visitors, "graph_metric")
assert %{"graph_metric" => "current_visitors"} = current_visitors
assert %{"graph_metric" => "visitors"} = unique_conversions
assert %{"graph_metric" => "events"} = total_conversions
end
@ -1258,7 +1277,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
res = json_response(conn, 200)
assert %{"name" => "Unique visitors", "value" => 3} in res["top_stats"]
assert %{"name" => "Unique visitors", "value" => 3, "graph_metric" => "total_visitors"} in res[
"top_stats"
]
end
test "returns converted visitors", %{conn: conn, site: site} do
@ -1381,13 +1402,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"},
"value" => %{"long" => "$1,659.50", "short" => "$1.7K", "value" => 1659.5},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$3,319.00", "short" => "$3.3K"},
"value" => %{"long" => "$3,319.00", "short" => "$3.3K", "value" => 3319.0},
"graph_metric" => "total_revenue"
} in top_stats
end
@ -1440,13 +1461,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"},
"value" => %{"long" => "$1,659.50", "short" => "$1.7K", "value" => 1659.5},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$6,638.00", "short" => "$6.6K"},
"value" => %{"long" => "$6,638.00", "short" => "$6.6K", "value" => 6638.0},
"graph_metric" => "total_revenue"
} in top_stats
end
@ -1516,13 +1537,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$1,000.00", "short" => "$1.0K"},
"value" => %{"long" => "$1,000.00", "short" => "$1.0K", "value" => 1000.0},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$2,000.00", "short" => "$2.0K"},
"value" => %{"long" => "$2,000.00", "short" => "$2.0K", "value" => 2000.0},
"graph_metric" => "total_revenue"
} in top_stats
end
@ -1538,13 +1559,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$0.00", "short" => "$0.0"},
"value" => %{"long" => "$0.00", "short" => "$0.0", "value" => 0.0},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$0.00", "short" => "$0.0"},
"value" => %{"long" => "$0.00", "short" => "$0.0", "value" => 0.0},
"graph_metric" => "total_revenue"
} in top_stats
end