Add report percentages to dashboard and details view (#5923)

* Update report percentages on dashboard and details view

* Add percentages to Countries, Regions, and Cities reports

* Add percentages to Channels, Sources, and UTM reports

* Add percentages to top pages, entry pages, and exit pages reports

* Update tests to include percentages

* Change dashboard copy from title case to sentence case

* Update details modal style

* Make animations snappier

* Introduce max height to modal and make inner content scrollable

* Improve modal mobile design

- Enable horizontal scroll for details modal on mobile
- Add responsive spacing and positioning to modal

* Added mobile tap behavior to external link in list report

* Show tooltips only when in comparison mode or when the number is abbreviated

* remove previously added showTooltip prop

- This isn't needed anymore since we now handle the tooltip logic in the MetricValue component

* Show long format upon hovering detailed view metrics

* Added mobile tapping behaviour to detailed view

* Added percentages to all detailed views

* Add mobile swipe-to-close behavior for modal

* Adjust sensitivity of modal drag to close

* Use hammerjs for swipe-to-close modal behaviour

* Prevent dragging if gesture starts inside table

* Show 2 decimal places for percentages < 0.1% across dashboard

* Adjust dark mode styles

* Add hover effect to external link icon

* Update tests to expect two-decimal percentages

* Undo hammer install and revert to old modal styling

* Remove CR and % columns from goals and custom props reports on dashboard, and show on hover in detailed view

* Remove unused constants

* Undo conversion rate on hover behaviour

- Unlike percentages, CR should show permanently.

* Show percentages permanently in custom props detailed view

* Adjust width of conversion metrics column

* Updated metric-value test

* Update top-bar test

* Added changelog entry

* Fix test expectations for percentages with imported data

- Update tests to expect correct percentages (≤100%) when imported data is included. These tests will fail until the percentage calculation bug is fixed, documenting the expected behavior.

* Add imported_visitors to tests to ensure correct total_visitors calculation

* Correct imported_visitors count in test
This commit is contained in:
Sanne de Vries 2025-12-16 13:43:16 +01:00 committed by GitHub
parent 6446e15871
commit dfeda94e06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1170 additions and 674 deletions

View File

@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
### Removed ### Removed
### Changed ### Changed

View File

@ -90,6 +90,7 @@
--color-gray-950: var(--color-zinc-950); --color-gray-950: var(--color-zinc-950);
/* Custom gray shades from config (override some zinc values) */ /* Custom gray shades from config (override some zinc values) */
--color-gray-75: rgb(247 247 248);
--color-gray-150: rgb(236 236 238); --color-gray-150: rgb(236 236 238);
--color-gray-750: rgb(50 50 54); --color-gray-750: rgb(50 50 54);
--color-gray-825: rgb(35 35 38); --color-gray-825: rgb(35 35 38);
@ -294,16 +295,12 @@ blockquote {
display: inline; display: inline;
} }
.table-striped tbody tr:nth-child(odd) { .table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-100); background-color: var(--color-gray-75);
} }
.dark .table-striped tbody tr:nth-child(odd) { .dark .table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-800); background-color: var(--color-gray-850);
}
.dark .table-striped tbody tr:nth-child(even) {
background-color: var(--color-gray-900);
} }
.fade-enter { .fade-enter {

View File

@ -32,33 +32,6 @@
overflow: auto; overflow: auto;
} }
.modal__container {
background-color: #fff;
padding: 1rem 2rem;
border-radius: 4px;
margin: 50px auto;
box-sizing: border-box;
min-height: 509px;
transition: height 200ms ease-in;
}
.modal__close {
position: fixed;
color: #b8c2cc;
font-size: 48px;
font-weight: bold;
top: 12px;
right: 24px;
}
.modal__close::before {
content: '\2715';
}
.modal__content {
margin-bottom: 2rem;
}
@keyframes mm-fade-in { @keyframes mm-fade-in {
from { from {
opacity: 0; opacity: 0;

View File

@ -66,7 +66,7 @@ export const SearchInput = ({
type="text" type="text"
placeholder={isFocused ? placeholderFocused : placeholderUnfocused} placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
className={classNames( className={classNames(
'dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 w-48 dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500', 'text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 max-w-64 w-full dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500',
className className
)} )}
onChange={debouncedOnSearchInputChange} onChange={debouncedOnSearchInputChange}

View File

@ -15,14 +15,18 @@ export const SortButton = ({
return ( return (
<button <button
onClick={toggleSort} onClick={toggleSort}
className={classNames('group', 'hover:underline', 'relative')} className={classNames(
'group',
'hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-100',
'relative'
)}
> >
{children} {children}
<span <span
title={next.hint} title={next.hint}
className={classNames( className={classNames(
'absolute', 'absolute',
'rounded inline-block h-4 w-4', 'rounded inline-block size-4',
'ml-1', 'ml-1',
{ {
[SortDirection.asc]: 'rotate-180', [SortDirection.asc]: 'rotate-180',
@ -30,9 +34,8 @@ export const SortButton = ({
}[sortDirection ?? next.direction], }[sortDirection ?? next.direction],
!sortDirection && 'opacity-0', !sortDirection && 'opacity-0',
!sortDirection && 'group-hover:opacity-100', !sortDirection && 'group-hover:opacity-100',
sortDirection &&
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900', 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'transition' 'transition-all duration-100'
)} )}
> >

View File

@ -21,7 +21,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" * Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/ */
renderValue?: (item: T) => ReactNode renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
/** Function used to create richer cells */ /** Function used to create richer cells */
renderItem?: (item: T) => ReactNode renderItem?: (item: T) => ReactNode
} }
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
return ( return (
<th <th
className={classNames( className={classNames(
'p-2 text-xs font-bold text-gray-500 dark:text-gray-400 tracking-wide', 'p-2 text-xs font-semibold text-gray-500 dark:text-gray-400',
className className
)} )}
align={align} align={align}
@ -58,7 +58,13 @@ export const TableCell = ({
align?: 'left' | 'right' align?: 'left' | 'right'
}) => { }) => {
return ( return (
<td className={classNames('p-2 font-medium', className)} align={align}> <td
className={classNames(
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
className
)}
align={align}
>
{children} {children}
</td> </td>
) )
@ -68,15 +74,42 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
rowIndex, rowIndex,
pageIndex, pageIndex,
item, item,
columns columns,
tappedRowName,
onRowTap
}: { }: {
rowIndex: number rowIndex: number
pageIndex?: number pageIndex?: number
item: T item: T
columns: ColumnConfiguraton<T>[] columns: ColumnConfiguraton<T>[]
tappedRowName?: string | null
onRowTap?: (rowName: string | null) => void
}) => { }) => {
const [isHovered, setIsHovered] = React.useState(false)
const rowName = (item as unknown as { name: string }).name
const isTapped = tappedRowName === rowName
const isRowActive = isHovered || isTapped
const handleRowClick = (e: React.MouseEvent) => {
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
if (onRowTap) {
if (isTapped) {
onRowTap(null)
} else {
onRowTap(rowName)
}
}
}
}
return ( return (
<tr className="text-sm dark:text-gray-200"> <tr
className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleRowClick}
>
{columns.map(({ key, width, align, renderValue, renderItem }) => ( {columns.map(({ key, width, align, renderValue, renderItem }) => (
<TableCell <TableCell
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`} key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`}
@ -86,7 +119,7 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
{renderItem {renderItem
? renderItem(item) ? renderItem(item)
: renderValue : renderValue
? renderValue(item) ? renderValue(item, isRowActive)
: (item[key] ?? '')} : (item[key] ?? '')}
</TableCell> </TableCell>
))} ))}
@ -101,6 +134,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns: ColumnConfiguraton<T>[] columns: ColumnConfiguraton<T>[]
data: T[] | { pages: T[][] } data: T[] | { pages: T[][] }
}) => { }) => {
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
const renderColumnLabel = (column: ColumnConfiguraton<T>) => { const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
if (column.metricWarning) { if (column.metricWarning) {
return ( return (
@ -125,13 +160,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
} }
return ( return (
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed"> <table className="border-collapse table-striped table-fixed w-max min-w-full">
<thead> <thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
<tr className="text-xs font-bold text-gray-500 dark:text-gray-400"> <tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
{columns.map((column) => ( {columns.map((column) => (
<TableHeaderCell <TableHeaderCell
key={`header_${String(column.key)}`} key={`header_${String(column.key)}`}
className={classNames('p-2 tracking-wide', column.width)} className={classNames('p-2', column.width)}
align={column.align} align={column.align}
> >
{column.onSort ? ( {column.onSort ? (
@ -156,6 +191,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns={columns} columns={columns}
rowIndex={rowIndex} rowIndex={rowIndex}
key={rowIndex} key={rowIndex}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/> />
)) ))
: data.pages.map((page, pageIndex) => : data.pages.map((page, pageIndex) =>
@ -166,6 +203,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
rowIndex={rowIndex} rowIndex={rowIndex}
pageIndex={pageIndex} pageIndex={pageIndex}
key={`page_${pageIndex}_row_${rowIndex}`} key={`page_${pageIndex}_row_${rowIndex}`}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/> />
)) ))
)} )}

View File

@ -160,7 +160,7 @@ const Items = ({
<SearchInput <SearchInput
searchRef={searchRef} searchRef={searchRef}
placeholderUnfocused="Press / to search" placeholderUnfocused="Press / to search"
className="ml-auto w-full py-1 text-sm" className="ml-auto w-full py-1"
onSearch={handleSearchInput} onSearch={handleSearchInput}
/> />
</div> </div>

View File

@ -84,7 +84,7 @@ export const SearchableSegmentsSection = ({
<SearchInput <SearchInput
searchRef={searchRef} searchRef={searchRef}
placeholderUnfocused="Press / to search" placeholderUnfocused="Press / to search"
className="ml-auto w-full py-1 text-sm" className="ml-auto w-full py-1"
onSearch={handleSearchInput} onSearch={handleSearchInput}
/> />
)} )}

View File

@ -92,7 +92,7 @@ test('user can open and close filters dropdown', async () => {
'Location', 'Location',
'Screen size', 'Screen size',
'Browser', 'Browser',
'Operating System', 'Operating system',
'Goal' 'Goal'
]) ])
await userEvent.click(toggleFilters) await userEvent.click(toggleFilters)

View File

@ -26,7 +26,7 @@ export default function Bar({
return ( return (
<div className="w-full h-full relative" style={style}> <div className="w-full h-full relative" style={style}>
<div <div
className={`absolute top-0 left-0 h-full rounded-sm transition-colors duration-150 ${bg || ''}`} className={`absolute top-0 left-0 h-full rounded-sm ${bg || ''}`}
style={{ width: `${width}%` }} style={{ width: `${width}%` }}
></div> ></div>
{children} {children}

View File

@ -54,7 +54,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
path: conversionsRoute.path, path: conversionsRoute.path,
search: (search) => search search: (search) => search
}} }}
color="bg-red-50 group-hover:bg-red-100" color="bg-red-50 group-hover/row:bg-red-100"
colMinWidth={90} colMinWidth={90}
/> />
) )

View File

@ -93,7 +93,6 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
getExternalLinkUrl={getExternalLinkUrlFactory()} getExternalLinkUrl={getExternalLinkUrlFactory()}
maybeHideDetails={true}
color="bg-red-50" color="bg-red-50"
colMinWidth={90} colMinWidth={90}
/> />

View File

@ -31,8 +31,8 @@ export const PROPS = 'props'
export const FUNNELS = 'funnels' export const FUNNELS = 'funnels'
export const sectionTitles = { export const sectionTitles = {
[CONVERSIONS]: 'Goal Conversions', [CONVERSIONS]: 'Goal conversions',
[PROPS]: 'Custom Properties', [PROPS]: 'Custom properties',
[FUNNELS]: 'Funnels' [FUNNELS]: 'Funnels'
} }

View File

@ -137,8 +137,7 @@ export default function Properties({ afterFetchData }) {
params: { propKey }, params: { propKey },
search: (search) => search search: (search) => search
}} }}
maybeHideDetails={true} color="bg-red-50 group-hover/row:bg-red-100"
color="bg-red-50 group-hover:bg-red-100"
colMinWidth={90} colMinWidth={90}
/> />
) )

View File

@ -76,8 +76,9 @@ function Browsers({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), !hasConversionGoalFilter(query) &&
!hasConversionGoalFilter(query) && metrics.createPercentage() metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -121,8 +122,9 @@ function BrowserVersions({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), !hasConversionGoalFilter(query) &&
!hasConversionGoalFilter(query) && metrics.createPercentage() metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -187,9 +189,11 @@ function OperatingSystems({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && !hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { hiddenonMobile: true } }) metrics.createPercentage({
meta: { showOnHover: true, hiddenOnMobile: true }
}),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -238,8 +242,9 @@ function OperatingSystemVersions({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), !hasConversionGoalFilter(query) &&
!hasConversionGoalFilter(query) && metrics.createPercentage() metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -281,8 +286,9 @@ function ScreenSizes({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), !hasConversionGoalFilter(query) &&
!hasConversionGoalFilter(query) && metrics.createPercentage() metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -432,7 +438,7 @@ export default function Devices() {
} }
return ( return (
<div> <div className="group/report overflow-x-hidden">
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">Devices</h3> <h3 className="font-bold dark:text-gray-100">Devices</h3>

View File

@ -1,17 +1,17 @@
export const METRIC_LABELS = { export const METRIC_LABELS = {
visitors: 'Visitors', visitors: 'Visitors',
pageviews: 'Pageviews', pageviews: 'Pageviews',
events: 'Total Conversions', events: 'Total conversions',
views_per_visit: 'Views per Visit', views_per_visit: 'Views per visit',
visits: 'Visits', visits: 'Visits',
bounce_rate: 'Bounce Rate', bounce_rate: 'Bounce rate',
visit_duration: 'Visit Duration', visit_duration: 'Visit duration',
conversions: 'Converted Visitors', conversions: 'Converted visitors',
conversion_rate: 'Conversion Rate', conversion_rate: 'Conversion rate',
average_revenue: 'Average Revenue', average_revenue: 'Average revenue',
total_revenue: 'Total Revenue', total_revenue: 'Total revenue',
scroll_depth: 'Scroll Depth', scroll_depth: 'Scroll depth',
time_on_page: 'Time on Page' time_on_page: 'Time on page'
} }
function plottable(dataArray) { function plottable(dataArray) {

View File

@ -37,6 +37,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -54,7 +56,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
renderIcon={renderIcon} renderIcon={renderIcon}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
@ -79,6 +81,8 @@ function Regions({ query, site, onClick, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -93,7 +97,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }} detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
renderIcon={renderIcon} renderIcon={renderIcon}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
@ -118,6 +122,8 @@ function Cities({ query, site, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -131,7 +137,7 @@ function Cities({ query, site, afterFetchData }) {
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }} detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
renderIcon={renderIcon} renderIcon={renderIcon}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
@ -247,7 +253,7 @@ class Locations extends React.Component {
render() { render() {
return ( return (
<div> <div className="group/report overflow-x-hidden">
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100"> <h3 className="font-bold dark:text-gray-100">

View File

@ -185,7 +185,7 @@ const WorldMap = ({
path: countriesRoute.path, path: countriesRoute.path,
search: (search: Record<string, unknown>) => search search: (search: Record<string, unknown>) => search
}} }}
className={undefined} className="mt-3"
onClick={undefined} onClick={undefined}
/> />
{site.isDbip && <GeolocationNotice />} {site.isDbip && <GeolocationNotice />}

View File

@ -11,12 +11,14 @@ import {
useRememberOrderBy useRememberOrderBy
} from '../../hooks/use-order-by' } from '../../hooks/use-order-by'
import { Metric } from '../reports/metrics' import { Metric } from '../reports/metrics'
import * as metricsModule from '../reports/metrics'
import { BreakdownResultMeta, DashboardQuery } from '../../query' import { BreakdownResultMeta, DashboardQuery } from '../../query'
import { ColumnConfiguraton } from '../../components/table' import { ColumnConfiguraton } from '../../components/table'
import { BreakdownTable } from './breakdown-table' import { BreakdownTable } from './breakdown-table'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
import { SharedReportProps } from '../reports/list' import { SharedReportProps } from '../reports/list'
import { hasConversionGoalFilter } from '../../util/filters'
export type ReportInfo = { export type ReportInfo = {
/** Title of the report to render on the top left. */ /** Title of the report to render on the top left. */
@ -35,6 +37,8 @@ type BreakdownModalProps = {
/** Function that must return a new query that contains appropriate search filter for searchValue param. */ /** Function that must return a new query that contains appropriate search filter for searchValue param. */
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
searchEnabled?: boolean searchEnabled?: boolean
/** When true, keep the percentage metric as a permanently visible, sortable column. */
showPercentageColumn?: boolean
} }
/** /**
@ -62,6 +66,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
renderIcon, renderIcon,
getExternalLinkUrl, getExternalLinkUrl,
searchEnabled = true, searchEnabled = true,
showPercentageColumn = false,
afterFetchData, afterFetchData,
afterFetchNextPage, afterFetchNextPage,
addSearchFilter, addSearchFilter,
@ -71,20 +76,28 @@ export default function BreakdownModal<TListItem extends { name: string }>({
const { query } = useQueryContext() const { query } = useQueryContext()
const [meta, setMeta] = useState<BreakdownResultMeta | null>(null) const [meta, setMeta] = useState<BreakdownResultMeta | null>(null)
const breakdownMetrics = useMemo(() => {
const hasPercentage = metrics.some((m) => m.key === 'percentage')
if (!hasPercentage && !hasConversionGoalFilter(query)) {
return [...metrics, metricsModule.createPercentage()]
}
return metrics
}, [metrics, query])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const defaultOrderBy = getStoredOrderBy({ const defaultOrderBy = getStoredOrderBy({
domain: site.domain, domain: site.domain,
reportInfo, reportInfo,
metrics, metrics: breakdownMetrics,
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : [] fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
}) })
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics, metrics: breakdownMetrics,
defaultOrderBy defaultOrderBy
}) })
useRememberOrderBy({ useRememberOrderBy({
effectiveOrderBy: orderBy, effectiveOrderBy: orderBy,
metrics, metrics: breakdownMetrics,
reportInfo reportInfo
}) })
const apiState = usePaginatedGetAPI< const apiState = usePaginatedGetAPI<
@ -125,7 +138,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
{ {
label: reportInfo.dimensionLabel, label: reportInfo.dimensionLabel,
key: 'name', key: 'name',
width: 'w-48 md:w-full flex items-center break-all', width: 'w-40 md:w-48',
align: 'left', align: 'left',
renderItem: (item) => ( renderItem: (item) => (
<NameCell <NameCell
@ -136,14 +149,23 @@ export default function BreakdownModal<TListItem extends { name: string }>({
/> />
) )
}, },
...metrics.map( ...breakdownMetrics
.filter((m) => showPercentageColumn || m.key !== 'percentage')
.map(
(m): ColumnConfiguraton<TListItem> => ({ (m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query), label: m.renderLabel(query),
key: m.key, key: m.key,
width: m.width, width: m.width,
align: 'right', align: 'right',
metricWarning: getMetricWarning(m, meta), metricWarning: getMetricWarning(m, meta),
renderValue: (item) => m.renderValue(item, meta), renderValue: (item, isRowHovered) =>
m.renderValue(
showPercentageColumn && m.key === 'visitors'
? { ...item, percentage: null }
: item,
meta,
{ detailedView: true, isRowHovered }
),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined, onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key] sortDirection: orderByDictionary[m.key]
}) })
@ -151,14 +173,15 @@ export default function BreakdownModal<TListItem extends { name: string }>({
], ],
[ [
reportInfo.dimensionLabel, reportInfo.dimensionLabel,
metrics, breakdownMetrics,
getFilterInfo, getFilterInfo,
query, query,
orderByDictionary, orderByDictionary,
toggleSortByMetric, toggleSortByMetric,
renderIcon, renderIcon,
getExternalLinkUrl, getExternalLinkUrl,
meta meta,
showPercentageColumn
] ]
) )
@ -190,7 +213,7 @@ const NameCell = <TListItem extends { name: string }>({
renderIcon?: (item: TListItem) => ReactNode renderIcon?: (item: TListItem) => ReactNode
getExternalLinkUrl?: (listItem: TListItem) => string getExternalLinkUrl?: (listItem: TListItem) => string
}) => ( }) => (
<> <div className="max-w-full break-all flex items-center">
{typeof renderIcon === 'function' && renderIcon(item)} {typeof renderIcon === 'function' && renderIcon(item)}
<DrilldownLink <DrilldownLink
path={rootRoute.path} path={rootRoute.path}
@ -203,7 +226,7 @@ const NameCell = <TListItem extends { name: string }>({
{typeof getExternalLinkUrl === 'function' && ( {typeof getExternalLinkUrl === 'function' && (
<ExternalLinkIcon url={getExternalLinkUrl(item)} /> <ExternalLinkIcon url={getExternalLinkUrl(item)} />
)} )}
</> </div>
) )
const ExternalLinkIcon = ({ url }: { url?: string }) => const ExternalLinkIcon = ({ url }: { url?: string }) =>

View File

@ -1,11 +1,12 @@
import React, { ReactNode, useRef } from 'react' import React, { ReactNode, useRef } from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { SearchInput } from '../../components/search-input' import { SearchInput } from '../../components/search-input'
import { ColumnConfiguraton, Table } from '../../components/table' import { ColumnConfiguraton, Table } from '../../components/table'
import RocketIcon from './rocket-icon' import RocketIcon from './rocket-icon'
import { QueryStatus } from '@tanstack/react-query' import { QueryStatus } from '@tanstack/react-query'
import { useAppNavigate } from '../../navigation/use-app-navigate'
const MIN_HEIGHT_PX = 500 import { rootRoute } from '../../router'
export const BreakdownTable = <TListItem extends { name: string }>({ export const BreakdownTable = <TListItem extends { name: string }>({
title, title,
@ -19,7 +20,8 @@ export const BreakdownTable = <TListItem extends { name: string }>({
data, data,
status, status,
error, error,
displayError displayError,
onClose
}: { }: {
title: ReactNode title: ReactNode
onSearch?: (input: string) => void onSearch?: (input: string) => void
@ -34,16 +36,21 @@ export const BreakdownTable = <TListItem extends { name: string }>({
error?: Error | null error?: Error | null
/** Controls whether the component displays API request errors or ignores them. */ /** Controls whether the component displays API request errors or ignores them. */
displayError?: boolean displayError?: boolean
onClose?: () => void
}) => { }) => {
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
const navigate = useAppNavigate()
const handleClose =
onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s }))
return ( return (
<div className="w-full h-full"> <>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center gap-4">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-4 w-full">
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1> <h1 className="shrink-0 mb-0.5 text-base md:text-lg font-bold dark:text-gray-100">
{title}
</h1>
{!isPending && isFetching && <SmallLoadingSpinner />} {!isPending && isFetching && <SmallLoadingSpinner />}
</div>
{!!onSearch && ( {!!onSearch && (
<SearchInput <SearchInput
searchRef={searchRef} searchRef={searchRef}
@ -54,8 +61,17 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/> />
)} )}
</div> </div>
<div className="my-4 border-b border-gray-300 dark:border-gray-700"></div> <button
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}> type="button"
onClick={handleClose}
aria-label="Close modal"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="size-5" />
</button>
</div>
<div className="my-3 md:my-4 border-b border-gray-250 dark:border-gray-750"></div>
<div className="flex-1 overflow-auto pr-4 -mr-4">
{displayError && status === 'error' && <ErrorMessage error={error} />} {displayError && status === 'error' && <ErrorMessage error={error} />}
{isPending && <InitialLoadingSpinner />} {isPending && <InitialLoadingSpinner />}
{data && <Table<TListItem> data={data} columns={columns} />} {data && <Table<TListItem> data={data} columns={columns} />}
@ -66,15 +82,12 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/> />
)} )}
</div> </div>
</div> </>
) )
} }
const InitialLoadingSpinner = () => ( const InitialLoadingSpinner = () => (
<div <div className="w-full h-full flex flex-col justify-center">
className="w-full h-full flex flex-col justify-center"
style={{ minHeight: `${MIN_HEIGHT_PX}px` }}
>
<div className="mx-auto loading"> <div className="mx-auto loading">
<div /> <div />
</div> </div>
@ -88,10 +101,7 @@ const SmallLoadingSpinner = () => (
) )
const ErrorMessage = ({ error }: { error?: unknown }) => ( const ErrorMessage = ({ error }: { error?: unknown }) => (
<div <div className="grid grid-rows-2 text-gray-700 dark:text-gray-300">
className="grid grid-rows-2 text-gray-700 dark:text-gray-300"
style={{ height: `${MIN_HEIGHT_PX}px` }}
>
<div className="text-center self-end"> <div className="text-center self-end">
<RocketIcon /> <RocketIcon />
</div> </div>

View File

@ -13,7 +13,7 @@ function ConversionsModal() {
const site = useSiteContext() const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Goal Conversions', title: 'Goal conversions',
dimension: 'goal', dimension: 'goal',
endpoint: url.apiPath(site, '/conversions'), endpoint: url.apiPath(site, '/conversions'),
dimensionLabel: 'Goal' dimensionLabel: 'Goal'

View File

@ -14,7 +14,7 @@ function BrowserVersionsModal() {
const site = useSiteContext() const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Browser Versions', title: 'Browser versions',
dimension: 'browser_version', dimension: 'browser_version',
endpoint: url.apiPath(site, '/browser-versions'), endpoint: url.apiPath(site, '/browser-versions'),
dimensionLabel: 'Browser version', dimensionLabel: 'Browser version',

View File

@ -14,7 +14,7 @@ export default function chooseMetrics(query, site) {
metrics.createTotalVisitors(), metrics.createTotalVisitors(),
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Conversions', renderLabel: (_query) => 'Conversions',
width: 'w-28' width: 'w-32 md:w-28'
}), }),
metrics.createConversionRate(), metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(), showRevenueMetrics && metrics.createTotalRevenue(),
@ -26,7 +26,7 @@ export default function chooseMetrics(query, site) {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}), }),
metrics.createPercentage() metrics.createPercentage()
] ]

View File

@ -14,7 +14,7 @@ function OperatingSystemVersionsModal() {
const site = useSiteContext() const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Operating System Versions', title: 'Operating system versions',
dimension: 'os_version', dimension: 'os_version',
endpoint: url.apiPath(site, '/operating-system-versions'), endpoint: url.apiPath(site, '/operating-system-versions'),
dimensionLabel: 'Operating system version', dimensionLabel: 'Operating system version',

View File

@ -14,7 +14,7 @@ function OperatingSystemsModal() {
const site = useSiteContext() const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Operating Systems', title: 'Operating systems',
dimension: 'os', dimension: 'os',
endpoint: url.apiPath(site, '/operating-systems'), endpoint: url.apiPath(site, '/operating-systems'),
dimensionLabel: 'Operating system', dimensionLabel: 'Operating system',

View File

@ -13,7 +13,7 @@ function ScreenSizesModal() {
const site = useSiteContext() const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Screen Sizes', title: 'Screen sizes',
dimension: 'screen', dimension: 'screen',
endpoint: url.apiPath(site, '/screen-sizes'), endpoint: url.apiPath(site, '/screen-sizes'),
dimensionLabel: 'Screen size', dimensionLabel: 'Screen size',

View File

@ -20,7 +20,7 @@ function EntryPagesModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: 'Entry Pages', title: 'Entry pages',
dimension: 'entry_page', dimension: 'entry_page',
endpoint: url.apiPath(site, '/entry-pages'), endpoint: url.apiPath(site, '/entry-pages'),
dimensionLabel: 'Entry page', dimensionLabel: 'Entry page',
@ -67,7 +67,7 @@ function EntryPagesModal() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }
@ -75,8 +75,8 @@ function EntryPagesModal() {
return [ return [
metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }),
metrics.createVisits({ metrics.createVisits({
renderLabel: (_query) => 'Total Entrances', renderLabel: (_query) => 'Total entrances',
width: 'w-36' width: 'w-32'
}), }),
metrics.createBounceRate(), metrics.createBounceRate(),
metrics.createVisitDuration() metrics.createVisitDuration()

View File

@ -17,7 +17,7 @@ function ExitPagesModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: 'Exit Pages', title: 'Exit pages',
dimension: 'exit_page', dimension: 'exit_page',
endpoint: url.apiPath(site, '/exit-pages'), endpoint: url.apiPath(site, '/exit-pages'),
dimensionLabel: 'Page url', dimensionLabel: 'Page url',
@ -64,7 +64,7 @@ function ExitPagesModal() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }
@ -75,7 +75,8 @@ function ExitPagesModal() {
sortable: true sortable: true
}), }),
metrics.createVisits({ metrics.createVisits({
renderLabel: (_query) => 'Total Exits', renderLabel: (_query) => 'Total exits',
width: 'w-32',
sortable: true sortable: true
}), }),
metrics.createExitRate() metrics.createExitRate()

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
@ -68,6 +69,7 @@ class FilterModal extends React.Component {
) )
this.handleKeydown = this.handleKeydown.bind(this) this.handleKeydown = this.handleKeydown.bind(this)
this.closeModal = this.closeModal.bind(this)
this.state = { this.state = {
query, query,
filterState, filterState,
@ -108,6 +110,13 @@ class FilterModal extends React.Component {
) )
} }
closeModal() {
this.props.navigate({
path: rootRoute.path,
search: (search) => search
})
}
selectFiltersAndCloseModal(filters) { selectFiltersAndCloseModal(filters) {
this.props.navigate({ this.props.navigate({
path: rootRoute.path, path: rootRoute.path,
@ -169,13 +178,23 @@ class FilterModal extends React.Component {
render() { render() {
return ( return (
<Modal maxWidth="460px"> <Modal maxWidth="460px" onClose={this.closeModal}>
<h1 className="text-xl font-bold dark:text-gray-100"> <div className="flex items-center justify-between gap-3">
<h1 className="text-base md:text-lg font-bold dark:text-gray-100">
Filter by {formatFilterGroup(this.props.modalType)} Filter by {formatFilterGroup(this.props.modalType)}
</h1> </h1>
<button
type="button"
onClick={this.closeModal}
aria-label="Close modal"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="size-5" />
</button>
</div>
<div className="mt-4 border-b border-gray-300 dark:border-gray-700"></div> <div className="mt-2 md:mt-4 border-b border-gray-300 dark:border-gray-700"></div>
<main className="modal__content"> <main>
<form <form
className="flex flex-col" className="flex flex-col"
onSubmit={this.handleSubmit.bind(this)} onSubmit={this.handleSubmit.bind(this)}
@ -192,7 +211,7 @@ class FilterModal extends React.Component {
/> />
))} ))}
<div className="mt-6 flex gap-x-4 items-center justify-start"> <div className="mt-6 mb-3 flex gap-x-4 items-center justify-start">
<button <button
type="submit" type="submit"
className="button !px-3" className="button !px-3"

View File

@ -12,21 +12,21 @@ import { SortDirection } from '../../hooks/use-order-by'
const VIEWS = { const VIEWS = {
countries: { countries: {
title: 'Top Countries', title: 'Top countries',
dimension: 'country', dimension: 'country',
endpoint: '/countries', endpoint: '/countries',
dimensionLabel: 'Country', dimensionLabel: 'Country',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
}, },
regions: { regions: {
title: 'Top Regions', title: 'Top regions',
dimension: 'region', dimension: 'region',
endpoint: '/regions', endpoint: '/regions',
dimensionLabel: 'Region', dimensionLabel: 'Region',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
}, },
cities: { cities: {
title: 'Top Cities', title: 'Top cities',
dimension: 'city', dimension: 'city',
endpoint: '/cities', endpoint: '/cities',
dimensionLabel: 'City', dimensionLabel: 'City',
@ -88,7 +88,7 @@ function LocationsModal({ currentView }) {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }

View File

@ -3,12 +3,8 @@ import { createPortal } from 'react-dom'
import { isModifierPressed, isTyping, Keybind } from '../../keybinding' import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
import { rootRoute } from '../../router' import { rootRoute } from '../../router'
import { useAppNavigate } from '../../navigation/use-app-navigate' import { useAppNavigate } from '../../navigation/use-app-navigate'
// This corresponds to the 'md' breakpoint on TailwindCSS.
const MD_WIDTH = 768
// We assume that the dashboard is by default opened on a desktop. This is also a fall-back for when, for any reason, the width is not ascertained. // We assume that the dashboard is by default opened on a desktop. This is also a fall-back for when, for any reason, the width is not ascertained.
const DEFAULT_WIDTH = 1080 const DEFAULT_WIDTH = 1080
class Modal extends React.Component { class Modal extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -27,26 +23,21 @@ class Modal extends React.Component {
window.addEventListener('resize', this.handleResize, false) window.addEventListener('resize', this.handleResize, false)
this.handleResize() this.handleResize()
} }
componentWillUnmount() { componentWillUnmount() {
document.body.style.overflow = null document.body.style.overflow = null
document.body.style.height = null document.body.style.height = null
document.removeEventListener('mousedown', this.handleClickOutside) document.removeEventListener('mousedown', this.handleClickOutside)
window.removeEventListener('resize', this.handleResize, false) window.removeEventListener('resize', this.handleResize, false)
} }
handleClickOutside(e) { handleClickOutside(e) {
if (this.node.current.contains(e.target)) { if (this.node.current.contains(e.target)) {
return return
} }
this.props.onClose() this.props.onClose()
} }
handleResize() { handleResize() {
this.setState({ viewport: window.innerWidth }) this.setState({ viewport: window.innerWidth })
} }
/** /**
* @description * @description
* Decide whether to set max-width, and if so, to what. * Decide whether to set max-width, and if so, to what.
@ -56,12 +47,11 @@ class Modal extends React.Component {
*/ */
getStyle() { getStyle() {
const { maxWidth } = this.props const { maxWidth } = this.props
const { viewport } = this.state
const styleObject = {} const styleObject = {}
if (maxWidth) { if (maxWidth) {
styleObject.maxWidth = maxWidth styleObject.maxWidth = maxWidth
} else { } else {
styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px' styleObject.maxWidth = '880px'
} }
return styleObject return styleObject
} }
@ -78,10 +68,10 @@ class Modal extends React.Component {
/> />
<div className="modal is-open" onClick={this.props.onClick}> <div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay"> <div className="modal__overlay">
<button className="modal__close"></button> <div className="[--gap:1rem] sm:[--gap:2rem] md:[--gap:4rem] flex h-full w-full items-center md:items-start justify-center p-[var(--gap)] box-border">
<div <div
ref={this.node} ref={this.node}
className="modal__container dark:bg-gray-900 focus:outline-hidden" className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-lg dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()} style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} tabIndex={0}
@ -91,6 +81,7 @@ class Modal extends React.Component {
</div> </div>
</div> </div>
</div> </div>
</div>
</>, </>,
document.getElementById('modal_root') document.getElementById('modal_root')
) )

View File

@ -20,7 +20,7 @@ function PagesModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: 'Top Pages', title: 'Top pages',
dimension: 'page', dimension: 'page',
endpoint: url.apiPath(site, '/pages'), endpoint: url.apiPath(site, '/pages'),
dimensionLabel: 'Page url', dimensionLabel: 'Page url',
@ -67,7 +67,7 @@ function PagesModal() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }

View File

@ -21,7 +21,7 @@ function PropsModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'),
dimension: propKey, dimension: propKey,
endpoint: url.apiPath( endpoint: url.apiPath(
site, site,
@ -71,6 +71,7 @@ function PropsModal() {
metrics={chooseMetrics()} metrics={chooseMetrics()}
getFilterInfo={getFilterInfo} getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter} addSearchFilter={addSearchFilter}
showPercentageColumn
/> />
</Modal> </Modal>
) )

View File

@ -74,7 +74,7 @@ function ReferrerDrilldownModal() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }

View File

@ -16,7 +16,7 @@ import { SourceFavicon } from '../sources/source-favicon'
const VIEWS = { const VIEWS = {
sources: { sources: {
info: { info: {
title: 'Top Sources', title: 'Top sources',
dimension: 'source', dimension: 'source',
endpoint: '/sources', endpoint: '/sources',
dimensionLabel: 'Source', dimensionLabel: 'Source',
@ -33,7 +33,7 @@ const VIEWS = {
}, },
channels: { channels: {
info: { info: {
title: 'Top Acquisition Channels', title: 'Top acquisition channels',
dimension: 'channel', dimension: 'channel',
endpoint: '/channels', endpoint: '/channels',
dimensionLabel: 'Channel', dimensionLabel: 'Channel',
@ -42,46 +42,46 @@ const VIEWS = {
}, },
utm_mediums: { utm_mediums: {
info: { info: {
title: 'Top UTM Mediums', title: 'Top UTM mediums',
dimension: 'utm_medium', dimension: 'utm_medium',
endpoint: '/utm_mediums', endpoint: '/utm_mediums',
dimensionLabel: 'UTM Medium', dimensionLabel: 'UTM medium',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
} }
}, },
utm_sources: { utm_sources: {
info: { info: {
title: 'Top UTM Sources', title: 'Top UTM sources',
dimension: 'utm_source', dimension: 'utm_source',
endpoint: '/utm_sources', endpoint: '/utm_sources',
dimensionLabel: 'UTM Source', dimensionLabel: 'UTM source',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
} }
}, },
utm_campaigns: { utm_campaigns: {
info: { info: {
title: 'Top UTM Campaigns', title: 'Top UTM campaigns',
dimension: 'utm_campaign', dimension: 'utm_campaign',
endpoint: '/utm_campaigns', endpoint: '/utm_campaigns',
dimensionLabel: 'UTM Campaign', dimensionLabel: 'UTM campaign',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
} }
}, },
utm_contents: { utm_contents: {
info: { info: {
title: 'Top UTM Contents', title: 'Top UTM contents',
dimension: 'utm_content', dimension: 'utm_content',
endpoint: '/utm_contents', endpoint: '/utm_contents',
dimensionLabel: 'UTM Content', dimensionLabel: 'UTM content',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
} }
}, },
utm_terms: { utm_terms: {
info: { info: {
title: 'Top UTM Terms', title: 'Top UTM terms',
dimension: 'utm_term', dimension: 'utm_term',
endpoint: '/utm_terms', endpoint: '/utm_terms',
dimensionLabel: 'UTM Term', dimensionLabel: 'UTM term',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
} }
} }
@ -140,7 +140,7 @@ function SourcesModal({ currentView }) {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', renderLabel: (_query) => 'Current visitors',
width: 'w-36' width: 'w-32'
}) })
] ]
} }

View File

@ -33,10 +33,12 @@ function EntryPages({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
defaultLabel: 'Unique Entrances', defaultLabel: 'Unique entrances',
width: 'w-36', width: 'w-36',
meta: { plot: true } meta: { plot: true }
}), }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -53,7 +55,7 @@ function EntryPages({ afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
getExternalLinkUrl={getExternalLinkUrl} getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
@ -79,10 +81,12 @@ function ExitPages({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
defaultLabel: 'Unique Exits', defaultLabel: 'Unique exits',
width: 'w-36', width: 'w-36',
meta: { plot: true } meta: { plot: true }
}), }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -99,7 +103,7 @@ function ExitPages({ afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
getExternalLinkUrl={getExternalLinkUrl} getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
@ -125,6 +129,8 @@ function TopPages({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -141,15 +147,15 @@ function TopPages({ afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
getExternalLinkUrl={getExternalLinkUrl} getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover:bg-orange-100" color="bg-orange-50 group-hover/row:bg-orange-100"
/> />
) )
} }
const labelFor = { const labelFor = {
pages: 'Top Pages', pages: 'Top pages',
'entry-pages': 'Entry Pages', 'entry-pages': 'Entry pages',
'exit-pages': 'Exit Pages' 'exit-pages': 'Exit pages'
} }
export default function Pages() { export default function Pages() {
@ -187,7 +193,7 @@ export default function Pages() {
} }
return ( return (
<div> <div className="group/report overflow-x-hidden">
{/* Header Container */} {/* Header Container */}
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
@ -201,9 +207,9 @@ export default function Pages() {
</div> </div>
<TabWrapper> <TabWrapper>
{[ {[
{ label: 'Top Pages', value: 'pages' }, { label: 'Top pages', value: 'pages' },
{ label: 'Entry Pages', value: 'entry-pages' }, { label: 'Entry pages', value: 'entry-pages' },
{ label: 'Exit Pages', value: 'exit-pages' } { label: 'Exit pages', value: 'exit-pages' }
].map(({ value, label }) => ( ].map(({ value, label }) => (
<TabButton <TabButton
active={mode === value} active={mode === value}

View File

@ -34,7 +34,7 @@ it('renders tilde for no change', () => {
const arrowElement = screen.getByTestId('change-arrow') const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('0%') expect(arrowElement).toHaveTextContent('0%')
}) })
it('inverts colors for positive bounce_rate change', () => { it('inverts colors for positive bounce_rate change', () => {

View File

@ -15,24 +15,22 @@ export function ChangeArrow({
className: string className: string
hideNumber?: boolean hideNumber?: boolean
}) { }) {
const formattedChange = hideNumber
? null
: ` ${numberShortFormatter(Math.abs(change))}%`
let icon = null let icon = null
const arrowClassName = classNames( const arrowClassName = classNames(
color(change, metric), color(change, metric),
'inline-block h-3 w-3 stroke-[1px] stroke-current' 'mb-0.5 inline-block size-3 stroke-[1px] stroke-current'
) )
if (change > 0) { if (change > 0) {
icon = <ArrowUpRightIcon className={arrowClassName} /> icon = <ArrowUpRightIcon className={arrowClassName} />
} else if (change < 0) { } else if (change < 0) {
icon = <ArrowDownRightIcon className={arrowClassName} /> icon = <ArrowDownRightIcon className={arrowClassName} />
} else if (change === 0 && !hideNumber) {
icon = <>&#12336;</>
} }
const formattedChange = hideNumber
? null
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
return ( return (
<span className={className} data-testid="change-arrow"> <span className={className} data-testid="change-arrow">
{icon} {icon}

View File

@ -26,27 +26,34 @@ const COL_MIN_WIDTH = 70
function ExternalLink<T>({ function ExternalLink<T>({
item, item,
getExternalLinkUrl getExternalLinkUrl,
isTapped
}: { }: {
item: T item: T
getExternalLinkUrl?: (item: T) => string getExternalLinkUrl?: (item: T) => string
isTapped?: boolean
}) { }) {
const dest = getExternalLinkUrl && getExternalLinkUrl(item) const dest = getExternalLinkUrl && getExternalLinkUrl(item)
if (dest) { if (dest) {
const className = isTapped
? 'visible md:invisible md:group-hover/row:visible'
: 'invisible md:group-hover/row:visible'
return ( return (
<a <a target="_blank" rel="noreferrer" href={dest} className={className}>
target="_blank"
rel="noreferrer"
href={dest}
className="w-4 h-4 invisible group-hover:visible"
>
<svg <svg
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" fill="none"
viewBox="0 0 20 20" viewBox="0 0 24 24"
className="inline size-3.5 mb-0.5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
> >
<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
<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> stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4M12 12l9-9-.303.303M14 3h7v7"
/>
</svg> </svg>
</a> </a>
) )
@ -88,11 +95,6 @@ type ListReportProps = {
colMinWidth?: number colMinWidth?: number
/** Navigation props to be passed to "More" link, if any. */ /** Navigation props to be passed to "More" link, if any. */
detailsLinkProps?: AppNavigationLinkProps detailsLinkProps?: AppNavigationLinkProps
/** Set this to `true` if the details button should be hidden on
* the condition that there are less than MAX_ITEMS entries in the list (i.e. nothing
* more to show).
*/
maybeHideDetails?: boolean
/** Function with additional action to be taken when a list entry is clicked. */ /** Function with additional action to be taken when a list entry is clicked. */
onClick?: () => void onClick?: () => void
/** Color of the comparison bars in light-mode. */ /** Color of the comparison bars in light-mode. */
@ -114,7 +116,6 @@ export default function ListReport<
colMinWidth = COL_MIN_WIDTH, colMinWidth = COL_MIN_WIDTH,
afterFetchData, afterFetchData,
detailsLinkProps, detailsLinkProps,
maybeHideDetails,
onClick, onClick,
color, color,
getFilterInfo, getFilterInfo,
@ -129,6 +130,7 @@ export default function ListReport<
meta: BreakdownResultMeta | null meta: BreakdownResultMeta | null
}>({ loading: true, list: null, meta: null }) }>({ loading: true, list: null, meta: null })
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [tappedRow, setTappedRow] = useState<string | null>(null)
const isRealtime = isRealTimeDashboard(query) const isRealtime = isRealTimeDashboard(query)
const goalFilterApplied = hasConversionGoalFilter(query) const goalFilterApplied = hasConversionGoalFilter(query)
@ -194,6 +196,38 @@ export default function ListReport<
} }
} }
function showOnHoverClass(metric: Metric, listItemName: string) {
if (!metric.meta.showOnHover) {
return ''
}
// On mobile: show if row is tapped, hide otherwise
// On desktop: slide in from right when hovering
if (tappedRow === listItemName) {
return 'translate-x-0 opacity-100 transition-all duration-150'
} else {
return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100'
}
}
function slideLeftClass(
metricIndex: number,
showOnHoverIndex: number,
hasShowOnHoverMetric: boolean,
listItemName: string
) {
// Columns before the showOnHover column should slide left when it appears
if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) {
return ''
}
if (tappedRow === listItemName) {
return 'transition-transform duration-150 translate-x-0'
} else {
return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0'
}
}
function renderReport() { function renderReport() {
if (state.list && state.list.length > 0) { if (state.list && state.list.length > 0) {
return ( return (
@ -206,12 +240,10 @@ export default function ListReport<
</FlipMove> </FlipMove>
</div> </div>
{!!detailsLinkProps && {!!detailsLinkProps && !state.loading && (
!state.loading &&
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
<MoreLink <MoreLink
onClick={undefined} onClick={undefined}
className={'mt-2'} className={'mt-3'}
linkProps={detailsLinkProps} linkProps={detailsLinkProps}
list={state.list} list={state.list}
/> />
@ -223,7 +255,9 @@ export default function ListReport<
} }
function renderReportHeader() { function renderReportHeader() {
const metricLabels = getAvailableMetrics().map((metric) => { const metricLabels = getAvailableMetrics()
.filter((metric) => !metric.meta.showOnHover)
.map((metric) => {
return ( return (
<div <div
key={metric.key} key={metric.key}
@ -236,7 +270,7 @@ export default function ListReport<
}) })
return ( return (
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400"> <div className="pt-3 w-full text-xs font-semibold text-gray-500 flex items-center dark:text-gray-400">
<span className="grow truncate">{keyLabel}</span> <span className="grow truncate">{keyLabel}</span>
{metricLabels} {metricLabels}
</div> </div>
@ -244,11 +278,22 @@ export default function ListReport<
} }
function renderRow(listItem: TListItem) { function renderRow(listItem: TListItem) {
const handleRowClick = (e: React.MouseEvent) => {
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
if (tappedRow === listItem.name) {
setTappedRow(null)
} else {
setTappedRow(listItem.name)
}
}
}
return ( return (
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}> <div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
<div <div
className="group flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm transition-colors duration-150" className="group/row flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm md:cursor-default cursor-pointer"
style={{ marginTop: ROW_GAP_HEIGHT }} style={{ marginTop: ROW_GAP_HEIGHT }}
onClick={handleRowClick}
> >
{renderBarFor(listItem)} {renderBarFor(listItem)}
{renderMetricValuesFor(listItem)} {renderMetricValuesFor(listItem)}
@ -258,7 +303,7 @@ export default function ListReport<
} }
function renderBarFor(listItem: TListItem) { function renderBarFor(listItem: TListItem) {
const lightBackground = color || 'bg-green-50 group-hover:bg-green-100' const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100'
const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key
return ( return (
@ -267,10 +312,10 @@ export default function ListReport<
maxWidthDeduction={undefined} maxWidthDeduction={undefined}
count={listItem[metricToPlot]} count={listItem[metricToPlot]}
all={state.list} all={state.list}
bg={`${lightBackground} dark:bg-gray-500/15 dark:group-hover:bg-gray-500/30`} bg={`${lightBackground} dark:bg-gray-500/15 dark:group-hover/row:bg-gray-500/30`}
plot={metricToPlot} plot={metricToPlot}
> >
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full"> <div className="flex justify-start items-center gap-x-1.5 px-2 py-1.5 text-sm dark:text-gray-300 relative z-9 break-all w-full">
<DrilldownLink <DrilldownLink
filterInfo={getFilterInfo(listItem)} filterInfo={getFilterInfo(listItem)}
onClick={onClick} onClick={onClick}
@ -285,6 +330,7 @@ export default function ListReport<
<ExternalLink <ExternalLink
item={listItem} item={listItem}
getExternalLinkUrl={getExternalLinkUrl} getExternalLinkUrl={getExternalLinkUrl}
isTapped={tappedRow === listItem.name}
/> />
</div> </div>
</Bar> </Bar>
@ -299,19 +345,36 @@ export default function ListReport<
} }
function renderMetricValuesFor(listItem: TListItem) { function renderMetricValuesFor(listItem: TListItem) {
return getAvailableMetrics().map((metric) => { const availableMetrics = getAvailableMetrics()
const showOnHoverIndex = availableMetrics.findIndex(
(m) => m.meta.showOnHover
)
const hasShowOnHoverMetric = showOnHoverIndex !== -1
return (
<>
{availableMetrics.map((metric, index) => {
const isShowOnHover = metric.meta.showOnHover
return ( return (
<div <div
key={`${listItem.name}__${metric.key}`} key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`} className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }} style={{ width: colMinWidth, minWidth: colMinWidth }}
> >
<span className="font-medium text-sm dark:text-gray-200 text-right"> <span
{metric.renderValue(listItem, state.meta)} className={`font-medium text-sm text-right ${isShowOnHover ? 'text-gray-500 group-hover/row:text-gray-800 dark:group-hover/row:text-gray-200' : 'text-gray-800 dark:text-gray-200'}`}
>
{metric.renderValue(listItem, state.meta, {
detailedView: false,
isRowHovered: false
})}
</span> </span>
</div> </div>
) )
}) })}
</>
)
} }
function renderLoading() { function renderLoading() {

View File

@ -11,10 +11,9 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
describe('single value', () => { describe('single value', () => {
it('renders small value', async () => { it('renders small value', async () => {
await renderWithTooltip(<MetricValue {...valueProps('visitors', 10)} />) render(<MetricValue {...valueProps('visitors', 10)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10') expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
}) })
it('renders large value', async () => { it('renders large value', async () => {
@ -25,23 +24,19 @@ describe('single value', () => {
}) })
it('renders percentages', async () => { it('renders percentages', async () => {
await renderWithTooltip(<MetricValue {...valueProps('bounce_rate', 5.3)} />) render(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%') expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
}) })
it('renders durations', async () => { it('renders durations', async () => {
await renderWithTooltip( render(<MetricValue {...valueProps('visit_duration', 60)} />)
<MetricValue {...valueProps('visit_duration', 60)} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s') expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
}) })
it('renders with custom formatter', async () => { it('renders with custom formatter', async () => {
await renderWithTooltip( render(
<MetricValue <MetricValue
{...valueProps('test_money', 5.3)} {...valueProps('test_money', 5.3)}
formatter={(value) => `${value}$`} formatter={(value) => `${value}$`}
@ -49,7 +44,6 @@ describe('single value', () => {
) )
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$') expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
}) })
it('renders revenue properly', async () => { it('renders revenue properly', async () => {
@ -80,9 +74,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 visitors', '10 visitors',
'↑ 100%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '↑ 100%',
'5 visitors', '5 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -98,9 +91,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'5 visitors', '5 visitors',
'↓ 50%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '↓ 50%',
'10 visitors', '10 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -116,9 +108,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 visitors', '10 visitors',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '0%',
'10 visitors', '10 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -136,9 +127,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 conversions', '10 conversions',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '0%',
'10 conversions', '10 conversions',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -154,14 +144,7 @@ describe('comparisons', () => {
) )
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ ['10% ', '01 Aug - 31 Aug', '0%', '10% ', '01 July - 31 July'].join('')
'10% ',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'10% ',
'01 July - 31 July'
].join('')
) )
}) })
@ -177,9 +160,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10$ test', '10$ test',
'↑ 100%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '↑ 100%',
'5$ test', '5$ test',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -200,9 +182,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'$1,659.50 average_revenue', '$1,659.50 average_revenue',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'vs', '0%',
'$1,659.50 average_revenue', '$1,659.50 average_revenue',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react' import React, { useMemo, useRef, useEffect } from 'react'
import { Metric } from '../../../types/query-api' import { Metric } from '../../../types/query-api'
import { Tooltip } from '../../util/tooltip' import { Tooltip } from '../../util/tooltip'
import { ChangeArrow } from './change-arrow' import { ChangeArrow } from './change-arrow'
@ -36,34 +36,66 @@ export default function MetricValue(props: {
renderLabel: (query: DashboardQuery) => string renderLabel: (query: DashboardQuery) => string
formatter?: (value: ValueType) => string formatter?: (value: ValueType) => string
meta: BreakdownResultMeta | null meta: BreakdownResultMeta | null
detailedView?: boolean
isRowHovered?: boolean
}) { }) {
const { query } = useQueryContext() const { query } = useQueryContext()
const portalRef = useRef<HTMLElement | null>(null)
const { metric, listItem } = props useEffect(() => {
if (typeof document !== 'undefined') {
portalRef.current = document.body
}
}, [])
const { metric, listItem, detailedView = false, isRowHovered = false } = props
const { value, comparison } = useMemo( const { value, comparison } = useMemo(
() => valueRenderProps(listItem, metric), () => valueRenderProps(listItem, metric),
[listItem, metric] [listItem, metric]
) )
const metricLabel = useMemo(() => props.renderLabel(query), [query, props]) const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
const shortFormatter = props.formatter ?? MetricFormatterShort[metric] const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
const longFormatter = props.formatter ?? MetricFormatterLong[metric]
const isAbbreviated = useMemo(() => {
if (value === null) return false
return shortFormatter(value) !== longFormatter(value)
}, [value, shortFormatter, longFormatter])
const showTooltip = detailedView
? !!comparison
: !!comparison || isAbbreviated
const shouldShowLongFormat =
detailedView && !comparison && isRowHovered && isAbbreviated
const displayFormatter = shouldShowLongFormat ? longFormatter : shortFormatter
const percentageValue = listItem['percentage' as Metric]
const shouldShowPercentage =
detailedView &&
metric === 'visitors' &&
isRowHovered &&
percentageValue != null
const percentageFormatter = MetricFormatterShort['percentage']
const percentageDisplay = shouldShowPercentage
? percentageFormatter(percentageValue)
: null
if (value === null && (!comparison || comparison.value === null)) { if (value === null && (!comparison || comparison.value === null)) {
return <span data-testid="metric-value">{shortFormatter(value)}</span> return <span data-testid="metric-value">{displayFormatter(value)}</span>
} }
return ( const valueContent = (
<Tooltip <span
info={ className={showTooltip ? 'cursor-default' : ''}
<ComparisonTooltipContent data-testid="metric-value"
value={value}
comparison={comparison}
metricLabel={metricLabel}
{...props}
/>
}
> >
<span className="cursor-default" data-testid="metric-value"> {percentageDisplay && (
{shortFormatter(value)} <span className="mr-3 text-gray-500 dark:text-gray-400">
{percentageDisplay}
</span>
)}
{displayFormatter(value)}
{comparison ? ( {comparison ? (
<ChangeArrow <ChangeArrow
change={comparison.change} change={comparison.change}
@ -73,6 +105,25 @@ export default function MetricValue(props: {
/> />
) : null} ) : null}
</span> </span>
)
if (!showTooltip) {
return valueContent
}
return (
<Tooltip
containerRef={portalRef as React.RefObject<HTMLElement>}
info={
<ComparisonTooltipContent
value={value}
comparison={comparison}
metricLabel={metricLabel}
{...props}
/>
}
>
{valueContent}
</Tooltip> </Tooltip>
) )
} }
@ -106,34 +157,34 @@ function ComparisonTooltipContent({
return ( return (
<div className="text-left whitespace-nowrap py-1 space-y-2"> <div className="text-left whitespace-nowrap py-1 space-y-2">
<div> <div>
<div className="flex items-center"> <div className="flex gap-x-4">
<span className="font-bold text-base"> <div className="flex flex-col">
<span className="font-medium text-sm/6 text-white">
{longFormatter(value)} {label} {longFormatter(value)} {label}
</span> </span>
<div className="font-normal text-xs text-white">
{meta.date_range_label}
</div>
</div>
<ChangeArrow <ChangeArrow
metric={metric} metric={metric}
change={comparison.change} change={comparison.change}
className="pl-4 text-xs text-gray-100" className="text-xs/6 font-medium text-white"
/> />
</div> </div>
<div className="font-normal text-xs">{meta.date_range_label}</div>
</div> </div>
<div>vs</div> <div className="w-full border-t border-gray-600"></div>
<div> <div>
<div className="font-bold text-base"> <div className="font-medium text-sm/6 text-gray-300/80">
{longFormatter(comparison.value)} {label} {longFormatter(comparison.value)} {label}
</div> </div>
<div className="font-normal text-xs"> <div className="font-normal text-xs text-gray-300/80">
{meta.comparison_date_range_label} {meta.comparison_date_range_label}
</div> </div>
</div> </div>
</div> </div>
) )
} else { } else {
return ( return <div className="whitespace-nowrap">{longFormatter(value)}</div>
<div className="whitespace-nowrap">
{longFormatter(value)} {label}
</div>
)
} }
} }

View File

@ -43,7 +43,8 @@ export class Metric {
this.renderValue = this.renderValue.bind(this) this.renderValue = this.renderValue.bind(this)
} }
renderValue(listItem, meta) { renderValue(listItem, meta, options = {}) {
const { detailedView = false, isRowHovered = false } = options
return ( return (
<MetricValue <MetricValue
listItem={listItem} listItem={listItem}
@ -51,6 +52,8 @@ export class Metric {
renderLabel={this.renderLabel} renderLabel={this.renderLabel}
meta={meta} meta={meta}
formatter={this.formatter} formatter={this.formatter}
detailedView={detailedView}
isRowHovered={isRowHovered}
/> />
) )
} }
@ -85,7 +88,7 @@ export const createVisitors = (props) => {
} }
return new Metric({ return new Metric({
width: 'w-24', width: 'w-36',
sortable: true, sortable: true,
...props, ...props,
key: 'visitors', key: 'visitors',
@ -96,7 +99,7 @@ export const createVisitors = (props) => {
export const createConversionRate = (props) => { export const createConversionRate = (props) => {
const renderLabel = (_query) => 'CR' const renderLabel = (_query) => 'CR'
return new Metric({ return new Metric({
width: 'w-24', width: 'w-28 md:w-24',
...props, ...props,
key: 'conversion_rate', key: 'conversion_rate',
renderLabel, renderLabel,
@ -116,13 +119,13 @@ export const createPercentage = (props) => {
} }
export const createEvents = (props) => { export const createEvents = (props) => {
return new Metric({ width: 'w-24', ...props, key: 'events', sortable: true }) return new Metric({ width: 'w-28', ...props, key: 'events', sortable: true })
} }
export const createTotalRevenue = (props) => { export const createTotalRevenue = (props) => {
const renderLabel = (_query) => 'Revenue' const renderLabel = (_query) => 'Revenue'
return new Metric({ return new Metric({
width: 'w-24', width: 'w-32',
...props, ...props,
key: 'total_revenue', key: 'total_revenue',
renderLabel, renderLabel,
@ -133,7 +136,7 @@ export const createTotalRevenue = (props) => {
export const createAverageRevenue = (props) => { export const createAverageRevenue = (props) => {
const renderLabel = (_query) => 'Average' const renderLabel = (_query) => 'Average'
return new Metric({ return new Metric({
width: 'w-24', width: 'w-28',
...props, ...props,
key: 'average_revenue', key: 'average_revenue',
renderLabel, renderLabel,
@ -142,9 +145,9 @@ export const createAverageRevenue = (props) => {
} }
export const createTotalVisitors = (props) => { export const createTotalVisitors = (props) => {
const renderLabel = (_query) => 'Total Visitors' const renderLabel = (_query) => 'Total visitors'
return new Metric({ return new Metric({
width: 'w-28', width: 'w-32',
...props, ...props,
key: 'total_visitors', key: 'total_visitors',
renderLabel, renderLabel,
@ -157,9 +160,9 @@ export const createVisits = (props) => {
} }
export const createVisitDuration = (props) => { export const createVisitDuration = (props) => {
const renderLabel = (_query) => 'Visit Duration' const renderLabel = (_query) => 'Visit duration'
return new Metric({ return new Metric({
width: 'w-36', width: 'w-28 md:w-24',
...props, ...props,
key: 'visit_duration', key: 'visit_duration',
renderLabel, renderLabel,
@ -168,9 +171,9 @@ export const createVisitDuration = (props) => {
} }
export const createBounceRate = (props) => { export const createBounceRate = (props) => {
const renderLabel = (_query) => 'Bounce Rate' const renderLabel = (_query) => 'Bounce rate'
return new Metric({ return new Metric({
width: 'w-28', width: 'w-28 md:w-24',
...props, ...props,
key: 'bounce_rate', key: 'bounce_rate',
renderLabel, renderLabel,
@ -190,9 +193,9 @@ export const createPageviews = (props) => {
} }
export const createTimeOnPage = (props) => { export const createTimeOnPage = (props) => {
const renderLabel = (_query) => 'Time on Page' const renderLabel = (_query) => 'Time on page'
return new Metric({ return new Metric({
width: 'w-32', width: 'w-28 md:w-24',
...props, ...props,
key: 'time_on_page', key: 'time_on_page',
renderLabel, renderLabel,
@ -201,9 +204,9 @@ export const createTimeOnPage = (props) => {
} }
export const createExitRate = (props) => { export const createExitRate = (props) => {
const renderLabel = (_query) => 'Exit Rate' const renderLabel = (_query) => 'Exit rate'
return new Metric({ return new Metric({
width: 'w-28', width: 'w-28 md:w-24',
...props, ...props,
key: 'exit_rate', key: 'exit_rate',
renderLabel, renderLabel,
@ -212,9 +215,9 @@ export const createExitRate = (props) => {
} }
export const createScrollDepth = (props) => { export const createScrollDepth = (props) => {
const renderLabel = (_query) => 'Scroll Depth' const renderLabel = (_query) => 'Scroll depth'
return new Metric({ return new Metric({
width: 'w-28', width: 'w-28 md:w-24',
...props, ...props,
key: 'scroll_depth', key: 'scroll_depth',
renderLabel, renderLabel,

View File

@ -149,7 +149,7 @@ export function SearchTerms() {
path: referrersGoogleRoute.path, path: referrersGoogleRoute.path,
search: (search: Record<string, unknown>) => search search: (search: Record<string, unknown>) => search
}} }}
className="w-full mt-2" className="w-full mt-3"
onClick={undefined} onClick={undefined}
/> />
</React.Fragment> </React.Fragment>

View File

@ -27,26 +27,26 @@ import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
const UTM_TAGS = { const UTM_TAGS = {
utm_medium: { utm_medium: {
title: 'UTM Mediums', title: 'UTM mediums',
label: 'Medium', label: 'Medium',
endpoint: '/utm_mediums' endpoint: '/utm_mediums'
}, },
utm_source: { utm_source: {
title: 'UTM Sources', title: 'UTM sources',
label: 'Source', label: 'Source',
endpoint: '/utm_sources' endpoint: '/utm_sources'
}, },
utm_campaign: { utm_campaign: {
title: 'UTM Campaigns', title: 'UTM campaigns',
label: 'Campaign', label: 'Campaign',
endpoint: '/utm_campaigns' endpoint: '/utm_campaigns'
}, },
utm_content: { utm_content: {
title: 'UTM Contents', title: 'UTM contents',
label: 'Content', label: 'Content',
endpoint: '/utm_contents' endpoint: '/utm_contents'
}, },
utm_term: { title: 'UTM Terms', label: 'Term', endpoint: '/utm_terms' } utm_term: { title: 'UTM terms', label: 'Term', endpoint: '/utm_terms' }
} }
function AllSources({ afterFetchData }) { function AllSources({ afterFetchData }) {
@ -70,6 +70,8 @@ function AllSources({ afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -83,7 +85,7 @@ function AllSources({ afterFetchData }) {
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }} detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }}
renderIcon={renderIcon} renderIcon={renderIcon}
color="bg-blue-50 group-hover:bg-blue-100" color="bg-blue-50 group-hover/row:bg-blue-100"
/> />
) )
} }
@ -106,6 +108,8 @@ function Channels({ onClick, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -122,7 +126,7 @@ function Channels({ onClick, afterFetchData }) {
path: channelsRoute.path, path: channelsRoute.path,
search: (search) => search search: (search) => search
}} }}
color="bg-blue-50 group-hover:bg-blue-100" color="bg-blue-50 group-hover/row:bg-blue-100"
/> />
) )
} }
@ -154,6 +158,8 @@ function UTMSources({ tab, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -166,14 +172,14 @@ function UTMSources({ tab, afterFetchData }) {
keyLabel={utmTag.label} keyLabel={utmTag.label}
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: route?.path, search: (search) => search }} detailsLinkProps={{ path: route?.path, search: (search) => search }}
color="bg-blue-50 group-hover:bg-blue-100" color="bg-blue-50 group-hover/row:bg-blue-100"
/> />
) )
} }
const labelFor = { const labelFor = {
channels: 'Top Channels', channels: 'Top channels',
all: 'Top Sources' all: 'Top sources'
} }
for (const [key, utm_tag] of Object.entries(UTM_TAGS)) { for (const [key, utm_tag] of Object.entries(UTM_TAGS)) {
@ -241,7 +247,7 @@ export default function SourceList() {
} }
return ( return (
<div> <div className="group/report overflow-x-hidden">
{/* Header Container */} {/* Header Container */}
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<div className="flex gap-x-1"> <div className="flex gap-x-1">

View File

@ -291,23 +291,23 @@ export const formattedFilters = {
prop_value: 'Value', prop_value: 'Value',
source: 'Source', source: 'Source',
channel: 'Channel', channel: 'Channel',
utm_medium: 'UTM Medium', utm_medium: 'UTM medium',
utm_source: 'UTM Source', utm_source: 'UTM source',
utm_campaign: 'UTM Campaign', utm_campaign: 'UTM campaign',
utm_content: 'UTM Content', utm_content: 'UTM content',
utm_term: 'UTM Term', utm_term: 'UTM term',
referrer: 'Referrer URL', referrer: 'Referrer URL',
screen: 'Screen size', screen: 'Screen size',
browser: 'Browser', browser: 'Browser',
browser_version: 'Browser Version', browser_version: 'Browser version',
os: 'Operating System', os: 'Operating system',
os_version: 'Operating System Version', os_version: 'Operating system version',
country: 'Country', country: 'Country',
region: 'Region', region: 'Region',
city: 'City', city: 'City',
page: 'Page', page: 'Page',
hostname: 'Hostname', hostname: 'Hostname',
entry_page: 'Entry Page', entry_page: 'Entry page',
exit_page: 'Exit Page', exit_page: 'Exit page',
segment: 'Segment' segment: 'Segment'
} }

View File

@ -69,7 +69,11 @@ export function durationFormatter(duration: number): string {
export function percentageFormatter(number: number | null): string { export function percentageFormatter(number: number | null): string {
if (typeof number === 'number') { if (typeof number === 'number') {
return number + '%' if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
return number.toFixed(2) + '%'
} else {
return number.toFixed(1).replace(/\.0$/, '') + '%'
}
} else { } else {
return '-' return '-'
} }

View File

@ -26,16 +26,14 @@ export function Tooltip({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>( const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null null
) )
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top', placement: 'top',
modifiers: [ modifiers: [
{ name: 'arrow', options: { element: arrowElement } },
{ {
name: 'offset', name: 'offset',
options: { options: {
offset: [0, 4] offset: [0, 6]
} }
}, },
...(boundary ...(boundary
@ -67,8 +65,6 @@ export function Tooltip({
popperStyle={styles.popper} popperStyle={styles.popper}
popperAttributes={attributes.popper} popperAttributes={attributes.popper}
setPopperElement={setPopperElement} setPopperElement={setPopperElement}
setArrowElement={setArrowElement}
arrowStyle={styles.arrow}
> >
{info} {info}
</TooltipMessage> </TooltipMessage>
@ -82,16 +78,12 @@ function TooltipMessage({
popperStyle, popperStyle,
popperAttributes, popperAttributes,
setPopperElement, setPopperElement,
setArrowElement,
arrowStyle,
children children
}: { }: {
containerRef?: RefObject<HTMLElement> containerRef?: RefObject<HTMLElement>
popperStyle: CSSProperties popperStyle: CSSProperties
arrowStyle: CSSProperties
popperAttributes?: Record<string, string> popperAttributes?: Record<string, string>
setPopperElement: (element: HTMLDivElement) => void setPopperElement: (element: HTMLDivElement) => void
setArrowElement: (element: HTMLDivElement) => void
children: ReactNode children: ReactNode
}) { }) {
const messageElement = ( const messageElement = (
@ -99,15 +91,10 @@ function TooltipMessage({
ref={setPopperElement} ref={setPopperElement}
style={popperStyle} style={popperStyle}
{...popperAttributes} {...popperAttributes}
className="z-50 p-2 rounded-sm text-sm text-gray-100 font-bold bg-gray-800 dark:bg-gray-700" className="z-[999] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
role="tooltip" role="tooltip"
> >
{children} {children}
<div
ref={setArrowElement}
style={arrowStyle}
className="tooltip-arrow"
></div>
</div> </div>
) )
if (containerRef) { if (containerRef) {

View File

@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
phx-target="#funnel-form" phx-target="#funnel-form"
phx-click-away="cancel-add-funnel" phx-click-away="cancel-add-funnel"
onkeydown="return event.key != 'Enter';" onkeydown="return event.key != 'Enter';"
class="bg-white dark:bg-gray-900 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8" class="bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8"
> >
<.title class="mb-6"> <.title class="mb-6">
{if @funnel, do: "Edit", else: "Add"} funnel {if @funnel, do: "Edit", else: "Add"} funnel

View File

@ -44,7 +44,7 @@ defmodule Plausible.Stats.SQL.SpecialMetrics do
|> select_merge_as([], %{ |> select_merge_as([], %{
percentage: percentage:
fragment( fragment(
"if(? > 0, round(? / ? * 100, 1), null)", "if(? > 0, round(? / ? * 100, 2), null)",
selected_as(:total_visitors), selected_as(:total_visitors),
selected_as(:visitors), selected_as(:visitors),
selected_as(:total_visitors) selected_as(:total_visitors)

View File

@ -475,7 +475,9 @@ defmodule PlausibleWeb.Api.StatsController do
pagination = parse_pagination(params) pagination = parse_pagination(params)
extra_metrics = extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: [] if params["detailed"],
do: [:percentage, :bounce_rate, :visit_duration],
else: [:percentage]
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
@ -513,7 +515,9 @@ defmodule PlausibleWeb.Api.StatsController do
pagination = parse_pagination(params) pagination = parse_pagination(params)
extra_metrics = extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: [] if params["detailed"],
do: [:percentage, :bounce_rate, :visit_duration],
else: [:percentage]
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
@ -606,7 +610,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -641,7 +645,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -676,7 +680,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -711,7 +715,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -746,7 +750,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -781,7 +785,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:bounce_rate, :visit_duration], extra_metrics: [:percentage, :bounce_rate, :visit_duration],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -873,7 +877,9 @@ defmodule PlausibleWeb.Api.StatsController do
pagination = parse_pagination(params) pagination = parse_pagination(params)
extra_metrics = extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: [] if params["detailed"],
do: [:percentage, :bounce_rate, :visit_duration],
else: [:percentage]
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
@ -902,9 +908,9 @@ defmodule PlausibleWeb.Api.StatsController do
extra_metrics = extra_metrics =
if params["detailed"] do if params["detailed"] do
[:pageviews, :bounce_rate, :time_on_page, :scroll_depth] [:percentage, :pageviews, :bounce_rate, :time_on_page, :scroll_depth]
else else
[] [:percentage]
end end
metrics = metrics =
@ -947,7 +953,7 @@ defmodule PlausibleWeb.Api.StatsController do
metrics = metrics =
breakdown_metrics(query, breakdown_metrics(query,
extra_metrics: [:visits, :visit_duration, :bounce_rate], extra_metrics: [:percentage, :visits, :visit_duration, :bounce_rate],
include_revenue?: !!params["detailed"] include_revenue?: !!params["detailed"]
) )
@ -990,9 +996,9 @@ defmodule PlausibleWeb.Api.StatsController do
extra_metrics = extra_metrics =
if TableDecider.sessions_join_events?(query) do if TableDecider.sessions_join_events?(query) do
[:visits] [:percentage, :visits]
else else
[:visits, :exit_rate] [:percentage, :visits, :exit_rate]
end end
metrics = metrics =
@ -1099,7 +1105,12 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:region") params = Map.put(params, "property", "visit:region")
query = Query.from(site, params, debug_metadata(conn)) query = Query.from(site, params, debug_metadata(conn))
pagination = parse_pagination(params) pagination = parse_pagination(params)
metrics = breakdown_metrics(query, include_revenue?: !!params["detailed"])
metrics =
breakdown_metrics(query,
extra_metrics: [:percentage],
include_revenue?: !!params["detailed"]
)
%{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination) %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination)
@ -1140,7 +1151,12 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:city") params = Map.put(params, "property", "visit:city")
query = Query.from(site, params, debug_metadata(conn)) query = Query.from(site, params, debug_metadata(conn))
pagination = parse_pagination(params) pagination = parse_pagination(params)
metrics = breakdown_metrics(query, include_revenue?: !!params["detailed"])
metrics =
breakdown_metrics(query,
extra_metrics: [:percentage],
include_revenue?: !!params["detailed"]
)
%{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination) %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination)

View File

@ -163,7 +163,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
def render(assigns) do def render(assigns) do
class = [ class = [
"md:w-1/2 w-full max-w-md mx-auto bg-white dark:bg-gray-900 shadow-xl rounded-lg px-8 pt-6 pb-8 top-24", "md:w-1/2 w-full max-w-md mx-auto bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 top-24",
assigns.class assigns.class
] ]

View File

@ -280,7 +280,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
}, },
%{ %{
"dimensions" => ["Firefox"], "dimensions" => ["Firefox"],
"metrics" => [2, 33.3], "metrics" => [2, 33.33],
"comparison" => %{ "comparison" => %{
"dimensions" => ["Firefox"], "dimensions" => ["Firefox"],
"metrics" => [4, 50.0], "metrics" => [4, 50.0],
@ -304,11 +304,11 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{ %{
"dimensions" => ["Safari"], "dimensions" => ["Safari"],
"metrics" => [1, 16.7], "metrics" => [1, 16.67],
"comparison" => %{ "comparison" => %{
"dimensions" => ["Safari"], "dimensions" => ["Safari"],
"metrics" => [3, 37.5], "metrics" => [3, 37.5],
"change" => [-67, -55] "change" => [-67, -56]
} }
} }
] ]
@ -361,7 +361,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{ %{
"dimensions" => ["Chrome"], "dimensions" => ["Chrome"],
"metrics" => [2, 66.7], "metrics" => [2, 66.67],
"comparison" => %{ "comparison" => %{
"dimensions" => ["Chrome"], "dimensions" => ["Chrome"],
"metrics" => [40, 40.0], "metrics" => [40, 40.0],
@ -370,7 +370,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
}, },
%{ %{
"dimensions" => ["Firefox"], "dimensions" => ["Firefox"],
"metrics" => [1, 33.3], "metrics" => [1, 33.33],
"comparison" => %{ "comparison" => %{
"dimensions" => ["Firefox"], "dimensions" => ["Firefox"],
"metrics" => [50, 50.0], "metrics" => [50, 50.0],

View File

@ -2045,8 +2045,8 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do
assert results == [ assert results == [
%{"dimensions" => [unquote(value1)], "metrics" => [3, 50]}, %{"dimensions" => [unquote(value1)], "metrics" => [3, 50]},
%{"dimensions" => [unquote(value2)], "metrics" => [2, 33.3]}, %{"dimensions" => [unquote(value2)], "metrics" => [2, 33.33]},
%{"dimensions" => [unquote(blank_value)], "metrics" => [1, 16.7]} %{"dimensions" => [unquote(blank_value)], "metrics" => [1, 16.67]}
] ]
end end
end end

View File

@ -14,8 +14,8 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
conn = get(conn, "/api/stats/#{site.domain}/browsers?period=day") conn = get(conn, "/api/stats/#{site.domain}/browsers?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Chrome", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Chrome", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Firefox", "visitors" => 1, "percentage" => 33.3} %{"name" => "Firefox", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -132,8 +132,8 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
conn2 = get(conn, "/api/stats/#{site.domain}/browsers?period=day&with_imported=true") conn2 = get(conn, "/api/stats/#{site.domain}/browsers?period=day&with_imported=true")
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{"name" => "Chrome", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Chrome", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Firefox", "visitors" => 1, "percentage" => 33.3} %{"name" => "Firefox", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -211,7 +211,7 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
%{ %{
"name" => "Chrome", "name" => "Chrome",
"visitors" => 2, "visitors" => 2,
"percentage" => 66.7, "percentage" => 66.67,
"comparison" => %{ "comparison" => %{
"visitors" => 0, "visitors" => 0,
"percentage" => 0.0, "percentage" => 0.0,
@ -221,7 +221,7 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
%{ %{
"name" => "Firefox", "name" => "Firefox",
"visitors" => 1, "visitors" => 1,
"percentage" => 33.3, "percentage" => 33.33,
"comparison" => %{ "comparison" => %{
"visitors" => 1, "visitors" => 1,
"percentage" => 50.0, "percentage" => 50.0,
@ -257,7 +257,7 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
%{ %{
"name" => "Chrome", "name" => "Chrome",
"visitors" => 2, "visitors" => 2,
"percentage" => 66.7, "percentage" => 66.67,
"comparison" => %{ "comparison" => %{
"visitors" => 1, "visitors" => 1,
"percentage" => 25.0, "percentage" => 25.0,
@ -452,14 +452,14 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
"name" => "Chrome 78.0", "name" => "Chrome 78.0",
"version" => "78.0", "version" => "78.0",
"visitors" => 2, "visitors" => 2,
"percentage" => 66.7, "percentage" => 66.67,
"browser" => "Chrome" "browser" => "Chrome"
}, },
%{ %{
"name" => "Chrome 77.0", "name" => "Chrome 77.0",
"version" => "77.0", "version" => "77.0",
"visitors" => 1, "visitors" => 1,
"percentage" => 33.3, "percentage" => 33.33,
"browser" => "Chrome" "browser" => "Chrome"
} }
] ]

View File

@ -38,8 +38,20 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day") conn = get(conn, "/api/stats/#{site.domain}/cities?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => 588_409, "country_flag" => "🇪🇪", "name" => "Tallinn", "visitors" => 3}, %{
%{"code" => 591_632, "country_flag" => "🇪🇪", "name" => "Kärdla", "visitors" => 2} "code" => 588_409,
"country_flag" => "🇪🇪",
"name" => "Tallinn",
"visitors" => 3,
"percentage" => 60.0
},
%{
"code" => 591_632,
"country_flag" => "🇪🇪",
"name" => "Kärdla",
"visitors" => 2,
"percentage" => 40.0
}
] ]
end end
@ -48,7 +60,13 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => 591_632, "country_flag" => "🇪🇪", "name" => "Kärdla", "visitors" => 2} %{
"code" => 591_632,
"country_flag" => "🇪🇪",
"name" => "Kärdla",
"visitors" => 2,
"percentage" => 100.0
}
] ]
end end
@ -62,8 +80,20 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&with_imported=true") conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&with_imported=true")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => 588_409, "country_flag" => "🇪🇪", "name" => "Tallinn", "visitors" => 4}, %{
%{"code" => 591_632, "country_flag" => "🇪🇪", "name" => "Kärdla", "visitors" => 2} "code" => 588_409,
"country_flag" => "🇪🇪",
"name" => "Tallinn",
"visitors" => 4,
"percentage" => 66.67
},
%{
"code" => 591_632,
"country_flag" => "🇪🇪",
"name" => "Kärdla",
"visitors" => 2,
"percentage" => 33.33
}
] ]
end end

View File

@ -23,7 +23,7 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do
"name" => "Estonia", "name" => "Estonia",
"flag" => "🇪🇪", "flag" => "🇪🇪",
"visitors" => 2, "visitors" => 2,
"percentage" => 66.7 "percentage" => 66.67
}, },
%{ %{
"code" => "GB", "code" => "GB",
@ -31,7 +31,7 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do
"name" => "United Kingdom", "name" => "United Kingdom",
"flag" => "🇬🇧", "flag" => "🇬🇧",
"visitors" => 1, "visitors" => 1,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]

View File

@ -89,13 +89,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2, "visitors" => 2,
"name" => "K2sna Kalle", "name" => "K2sna Kalle",
"events" => 2, "events" => 2,
"percentage" => 66.7 "percentage" => 66.67
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"name" => "(none)", "name" => "(none)",
"events" => 1, "events" => 1,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]
end end
@ -135,7 +135,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2, "visitors" => 2,
"name" => "Teet", "name" => "Teet",
"events" => 2, "events" => 2,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]
@ -144,7 +144,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 1, "visitors" => 1,
"name" => "(none)", "name" => "(none)",
"events" => 1, "events" => 1,
"percentage" => 16.7 "percentage" => 16.67
} }
] ]
end end
@ -1082,13 +1082,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2, "visitors" => 2,
"name" => "K2sna Kalle", "name" => "K2sna Kalle",
"events" => 2, "events" => 2,
"percentage" => 66.7 "percentage" => 66.67
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"name" => "Sipsik", "name" => "Sipsik",
"events" => 1, "events" => 1,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]
end end
@ -1121,13 +1121,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2, "visitors" => 2,
"name" => "bar", "name" => "bar",
"events" => 2, "events" => 2,
"percentage" => 66.7 "percentage" => 66.67
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"name" => "foobar", "name" => "foobar",
"events" => 1, "events" => 1,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]
end end

View File

@ -154,6 +154,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
) )
]) ])
populate_stats(site, import_id, [
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-31]),
build(:imported_visitors, date: ~D[2021-01-31])
])
import_data( import_data(
[ [
%{ %{
@ -280,11 +289,11 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
|> Enum.sort() |> Enum.sort()
assert results == [ assert results == [
%{"name" => "A Nice Newsletter", "visitors" => 1}, %{"name" => "A Nice Newsletter", "visitors" => 1, "percentage" => 11.11},
%{"name" => "Direct / None", "visitors" => 1}, %{"name" => "Direct / None", "visitors" => 1, "percentage" => 11.11},
%{"name" => "DuckDuckGo", "visitors" => 2}, %{"name" => "DuckDuckGo", "visitors" => 2, "percentage" => 22.22},
%{"name" => "Google", "visitors" => 4}, %{"name" => "Google", "visitors" => 4, "percentage" => 44.44},
%{"name" => "Twitter", "visitors" => 1} %{"name" => "Twitter", "visitors" => 1, "percentage" => 11.11}
] ]
end end
@ -415,10 +424,10 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
|> Enum.sort() |> Enum.sort()
assert results == [ assert results == [
%{"name" => "(not set)", "visitors" => 1}, %{"name" => "(not set)", "visitors" => 1, "percentage" => 33.33},
%{"name" => "Direct", "visitors" => 2}, %{"name" => "Direct", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Organic Search", "visitors" => 3}, %{"name" => "Organic Search", "visitors" => 3, "percentage" => 100.0},
%{"name" => "Paid Search", "visitors" => 2} %{"name" => "Paid Search", "visitors" => 2, "percentage" => 66.67}
] ]
end end
@ -492,8 +501,9 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
%{ %{
"bounce_rate" => 100.0, "bounce_rate" => 100.0,
"name" => "social", "name" => "social",
"visit_duration" => 20, "visit_duration" => 20.0,
"visitors" => 3 "visitors" => 3,
"percentage" => 100.0
} }
] ]
end end
@ -581,13 +591,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "august", "name" => "august",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50.0, "bounce_rate" => 50.0,
"visit_duration" => 50.0 "visit_duration" => 50.0,
"percentage" => 50.0
}, },
%{ %{
"name" => "profile", "name" => "profile",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100.0, "bounce_rate" => 100.0,
"visit_duration" => 50.0 "visit_duration" => 50.0,
"percentage" => 50.0
} }
] ]
end end
@ -676,13 +688,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "Sweden", "name" => "Sweden",
"visitors" => 3, "visitors" => 3,
"bounce_rate" => 67.0, "bounce_rate" => 67.0,
"visit_duration" => 33.0 "visit_duration" => 33.0,
"percentage" => 60.0
}, },
%{ %{
"name" => "oat milk", "name" => "oat milk",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100.0, "bounce_rate" => 100.0,
"visit_duration" => 50.0 "visit_duration" => 50.0,
"percentage" => 40.0
} }
] ]
end end
@ -770,13 +784,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "ad", "name" => "ad",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100.0, "bounce_rate" => 100.0,
"visit_duration" => 50.0 "visit_duration" => 50.0,
"percentage" => 50.0
}, },
%{ %{
"name" => "blog", "name" => "blog",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50.0, "bounce_rate" => 50.0,
"visit_duration" => 50.0 "visit_duration" => 50.0,
"percentage" => 50.0
} }
] ]
end end
@ -801,6 +817,13 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
) )
]) ])
populate_stats(site, import_id, [
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01])
])
import_data( import_data(
[ [
%{ %{
@ -877,12 +900,13 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{ %{
"bounce_rate" => 0, "bounce_rate" => 0.0,
"time_on_page" => 60, "time_on_page" => 60,
"visitors" => 3, "visitors" => 3,
"pageviews" => 4, "pageviews" => 4,
"scroll_depth" => nil, "scroll_depth" => nil,
"name" => "/some-other-page" "name" => "/some-other-page",
"percentage" => 60.0
}, },
%{ %{
"bounce_rate" => 25.0, "bounce_rate" => 25.0,
@ -890,7 +914,8 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"visitors" => 2, "visitors" => 2,
"pageviews" => 2, "pageviews" => 2,
"scroll_depth" => nil, "scroll_depth" => nil,
"name" => "/" "name" => "/",
"percentage" => 40.0
} }
] ]
end end
@ -960,12 +985,19 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => 588_335, "name" => "Tartu", "visitors" => 1, "country_flag" => "🇪🇪"}, %{
"code" => 588_335,
"name" => "Tartu",
"visitors" => 1,
"country_flag" => "🇪🇪",
"percentage" => 50.0
},
%{ %{
"code" => 2_650_225, "code" => 2_650_225,
"name" => "Edinburgh", "name" => "Edinburgh",
"visitors" => 1, "visitors" => 1,
"country_flag" => "🇬🇧" "country_flag" => "🇬🇧",
"percentage" => 50.0
} }
] ]
end end

View File

@ -14,8 +14,8 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
conn = get(conn, "/api/stats/#{site.domain}/operating-systems?period=day") conn = get(conn, "/api/stats/#{site.domain}/operating-systems?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Mac", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Mac", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Android", "visitors" => 1, "percentage" => 33.3} %{"name" => "Android", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -175,8 +175,8 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
conn1 = get(conn, "/api/stats/#{site.domain}/operating-systems?period=day") conn1 = get(conn, "/api/stats/#{site.domain}/operating-systems?period=day")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "Mac", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Mac", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Android", "visitors" => 1, "percentage" => 33.3} %{"name" => "Android", "visitors" => 1, "percentage" => 33.33}
] ]
conn2 = conn2 =
@ -357,14 +357,14 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
%{ %{
"name" => "Mac 10.16", "name" => "Mac 10.16",
"visitors" => 2, "visitors" => 2,
"percentage" => 66.7, "percentage" => 66.67,
"os" => "Mac", "os" => "Mac",
"version" => "10.16" "version" => "10.16"
}, },
%{ %{
"name" => "Mac 10.15", "name" => "Mac 10.15",
"visitors" => 1, "visitors" => 1,
"percentage" => 33.3, "percentage" => 33.33,
"os" => "Mac", "os" => "Mac",
"version" => "10.15" "version" => "10.15"
} }

View File

@ -24,9 +24,9 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 3, "name" => "/"}, %{"visitors" => 3, "name" => "/", "percentage" => 50.0},
%{"visitors" => 2, "name" => "/register"}, %{"visitors" => 2, "name" => "/register", "percentage" => 33.33},
%{"visitors" => 1, "name" => "/contact"} %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}
] ]
end end
@ -46,18 +46,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 3, "name" => "/"}, %{"visitors" => 3, "name" => "/", "percentage" => 50.0},
%{"visitors" => 2, "name" => "/register"}, %{"visitors" => 2, "name" => "/register", "percentage" => 33.33},
%{"visitors" => 1, "name" => "/contact"}, %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67},
%{"visitors" => 1, "name" => "/landing"} %{"visitors" => 1, "name" => "/landing", "percentage" => 16.67}
] ]
filters = Jason.encode!([[:is, "event:hostname", ["d.example.com"]]]) filters = Jason.encode!([[:is, "event:hostname", ["d.example.com"]]])
conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 2, "name" => "/register"}, %{"visitors" => 2, "name" => "/register", "percentage" => 66.67},
%{"visitors" => 1, "name" => "/"} %{"visitors" => 1, "name" => "/", "percentage" => 33.33}
] ]
end end
@ -80,7 +80,7 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 1, "name" => "/blog/john-1"} %{"visitors" => 1, "name" => "/blog/john-1", "percentage" => 100.0}
] ]
end end
@ -106,8 +106,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 1, "name" => "/"}, %{"visitors" => 1, "name" => "/", "percentage" => 50.0},
%{"visitors" => 1, "name" => "/blog/other-post"} %{"visitors" => 1, "name" => "/blog/other-post", "percentage" => 50.0}
] ]
end end
@ -143,8 +143,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 1, "name" => "/1"}, %{"visitors" => 1, "name" => "/1", "percentage" => 50.0},
%{"visitors" => 1, "name" => "/2"} %{"visitors" => 1, "name" => "/2", "percentage" => 50.0}
] ]
end end
@ -185,9 +185,9 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 1, "name" => "/1"}, %{"visitors" => 1, "name" => "/1", "percentage" => 33.33},
%{"visitors" => 1, "name" => "/2"}, %{"visitors" => 1, "name" => "/2", "percentage" => 33.33},
%{"visitors" => 1, "name" => "/6"} %{"visitors" => 1, "name" => "/6", "percentage" => 33.33}
] ]
end end
@ -228,7 +228,7 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 1, "name" => "/1"} %{"visitors" => 1, "name" => "/1", "percentage" => 100.0}
] ]
end end
@ -319,7 +319,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 315, "time_on_page" => 315,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/blog/john-1", "name" => "/blog/john-1",
@ -327,7 +328,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -419,7 +421,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 120, "time_on_page" => 120,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/blog/other-post", "name" => "/blog/other-post",
@ -427,7 +430,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -500,7 +504,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"time_on_page" => 45, "time_on_page" => 45,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/blog/other-post", "name" => "/blog/other-post",
@ -508,7 +513,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -589,7 +595,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/blog/john-1", "name" => "/blog/john-1",
@ -597,7 +604,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -645,7 +653,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{ %{
"name" => "/firefox", "name" => "/firefox",
"visitors" => 2 "visitors" => 2,
"percentage" => 100.0
} }
] ]
end end
@ -685,7 +694,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{ %{
"name" => "/safari", "name" => "/safari",
"visitors" => 1 "visitors" => 1,
"percentage" => 100.0
} }
] ]
end end
@ -758,7 +768,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 3, "pageviews" => 3,
"bounce_rate" => 50, "bounce_rate" => 50,
"time_on_page" => 90, "time_on_page" => 90,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
} }
] ]
end end
@ -831,7 +842,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 25 "scroll_depth" => 25,
"percentage" => 66.67
}, },
%{ %{
"name" => "/blog", "name" => "/blog",
@ -839,14 +851,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 4, "pageviews" => 4,
"bounce_rate" => 33, "bounce_rate" => 33,
"time_on_page" => 80, "time_on_page" => 80,
"scroll_depth" => 60 "scroll_depth" => 60,
"percentage" => 100.0
} }
] ]
end end
test "calculates scroll_depth from native and imported data combined", %{ test "calculates scroll_depth from native and imported data combined", %{
conn: conn, conn: conn,
site: site site: site,
site_import: site_import
} do } do
populate_stats(site, [ populate_stats(site, [
build(:pageview, build(:pageview,
@ -873,6 +887,12 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
]) ])
populate_stats(site, site_import.id, [
build(:imported_visitors, date: ~D[2020-01-01]),
build(:imported_visitors, date: ~D[2020-01-01]),
build(:imported_visitors, date: ~D[2020-01-01])
])
conn = conn =
get( get(
conn, conn,
@ -886,14 +906,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 4, "pageviews" => 4,
"bounce_rate" => 100, "bounce_rate" => 100,
"time_on_page" => 28, "time_on_page" => 28,
"scroll_depth" => 50 "scroll_depth" => 50,
"percentage" => 100.0
} }
] ]
end end
test "handles missing scroll_depth data from native and imported sources", %{ test "handles missing scroll_depth data from native and imported sources", %{
conn: conn, conn: conn,
site: site site: site,
site_import: site_import
} do } do
populate_stats(site, [ populate_stats(site, [
build(:pageview, build(:pageview,
@ -942,6 +964,12 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
]) ])
populate_stats(
site,
site_import.id,
for(_ <- 1..24, do: build(:imported_visitors, date: ~D[2020-01-01]))
)
conn = conn =
get( get(
conn, conn,
@ -955,7 +983,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 5, "pageviews" => 5,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 48, "time_on_page" => 48,
"scroll_depth" => 50 "scroll_depth" => 50,
"percentage" => 20.0
}, },
%{ %{
"name" => "/native-only", "name" => "/native-only",
@ -963,7 +992,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 40 "scroll_depth" => 40,
"percentage" => 4.0
}, },
%{ %{
"name" => "/imported-only", "name" => "/imported-only",
@ -971,7 +1001,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 30, "pageviews" => 30,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 10 "scroll_depth" => 10,
"percentage" => 80.0
} }
] ]
end end
@ -1017,7 +1048,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 160, "pageviews" => 160,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 10 "scroll_depth" => 10,
"percentage" => nil
} }
] ]
end end
@ -1097,7 +1129,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 3, "pageviews" => 3,
"bounce_rate" => 50, "bounce_rate" => 50,
"time_on_page" => 75, "time_on_page" => 75,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "/about", "name" => "/about",
@ -1105,7 +1138,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 33.33
} }
] ]
end end
@ -1185,7 +1219,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 3, "pageviews" => 3,
"bounce_rate" => 50, "bounce_rate" => 50,
"time_on_page" => 75, "time_on_page" => 75,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
} }
] ]
end end
@ -1265,7 +1300,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "/blog/post-1", "name" => "/blog/post-1",
@ -1273,7 +1309,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 33.33
}, },
%{ %{
"name" => "/blog/post-2", "name" => "/blog/post-2",
@ -1281,7 +1318,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 33.33
} }
] ]
end end
@ -1339,7 +1377,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 60, "time_on_page" => 60,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/blog/(/post-2", "name" => "/blog/(/post-2",
@ -1347,7 +1386,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
} }
] ]
end end
@ -1427,7 +1467,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"time_on_page" => 600, "time_on_page" => 600,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"name" => "/about", "name" => "/about",
@ -1435,7 +1476,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"time_on_page" => 30, "time_on_page" => 30,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -1455,17 +1497,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn1 = get(conn, "/api/stats/#{site.domain}/pages?period=day") conn1 = get(conn, "/api/stats/#{site.domain}/pages?period=day")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"visitors" => 3, "name" => "/"}, %{"visitors" => 3, "name" => "/", "percentage" => 50.0},
%{"visitors" => 2, "name" => "/register"}, %{"visitors" => 2, "name" => "/register", "percentage" => 33.33},
%{"visitors" => 1, "name" => "/contact"} %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}
] ]
conn2 = get(conn, "/api/stats/#{site.domain}/pages?period=day&with_imported=true") conn2 = get(conn, "/api/stats/#{site.domain}/pages?period=day&with_imported=true")
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{"visitors" => 4, "name" => "/"}, %{"visitors" => 4, "name" => "/", "percentage" => 66.67},
%{"visitors" => 3, "name" => "/register"}, %{"visitors" => 3, "name" => "/register", "percentage" => 50.0},
%{"visitors" => 1, "name" => "/contact"} %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}
] ]
end end
@ -1559,7 +1601,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visitors" => 2, "visitors" => 2,
"pageviews" => 2, "pageviews" => 2,
"name" => "/", "name" => "/",
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"bounce_rate" => 0, "bounce_rate" => 0,
@ -1567,7 +1610,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visitors" => 1, "visitors" => 1,
"pageviews" => 1, "pageviews" => 1,
"name" => "/some-other-page", "name" => "/some-other-page",
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
@ -1608,7 +1652,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 2, "visitors" => 2,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 100.0
} }
] ]
end end
@ -1744,7 +1789,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 3, "pageviews" => 3,
"time_on_page" => 435, "time_on_page" => 435,
"visitors" => 2, "visitors" => 2,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
}, },
%{ %{
"bounce_rate" => 0, "bounce_rate" => 0,
@ -1752,14 +1798,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"time_on_page" => 120, "time_on_page" => 120,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 50.0
} }
] ]
end end
test "calculates bounce rate and time on page for pages with imported data", %{ test "calculates bounce rate and time on page for pages with imported data", %{
conn: conn, conn: conn,
site: site site: site,
site_import: site_import
} do } do
populate_stats(site, [ populate_stats(site, [
build(:pageview, build(:pageview,
@ -1815,6 +1863,12 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
]) ])
populate_stats(site, site_import.id, [
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01])
])
conn = conn =
get( get(
conn, conn,
@ -1828,7 +1882,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visitors" => 3, "visitors" => 3,
"pageviews" => 3, "pageviews" => 3,
"scroll_depth" => 0, "scroll_depth" => 0,
"name" => "/" "name" => "/",
"percentage" => 60.0
}, },
%{ %{
"bounce_rate" => 0, "bounce_rate" => 0,
@ -1836,7 +1891,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visitors" => 2, "visitors" => 2,
"pageviews" => 2, "pageviews" => 2,
"scroll_depth" => 0, "scroll_depth" => 0,
"name" => "/some-other-page" "name" => "/some-other-page",
"percentage" => 40.0
} }
] ]
end end
@ -1851,8 +1907,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime") conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"visitors" => 2, "name" => "/page1"}, %{"visitors" => 2, "name" => "/page1", "percentage" => 66.67},
%{"visitors" => 1, "name" => "/page2"} %{"visitors" => 1, "name" => "/page2", "percentage" => 33.33}
] ]
end end
@ -1926,7 +1982,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 4, "pageviews" => 4,
"time_on_page" => 90.0, "time_on_page" => 90.0,
"visitors" => 4, "visitors" => 4,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 100.0
} }
] ]
end end
@ -1989,7 +2046,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 4, "pageviews" => 4,
"time_on_page" => 90.0, "time_on_page" => 90.0,
"visitors" => 4, "visitors" => 4,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 80.0
}, },
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100,
@ -1997,7 +2055,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"time_on_page" => 10.0, "time_on_page" => 10.0,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 20.0
} }
] ]
end end
@ -2055,20 +2114,22 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{ %{
"bounce_rate" => 50, "bounce_rate" => 50.0,
"name" => "/aaa", "name" => "/aaa",
"pageviews" => 4, "pageviews" => 4,
"time_on_page" => 90.0, "time_on_page" => 90,
"visitors" => 4, "visitors" => 4,
"scroll_depth" => 0 "scroll_depth" => 0,
"percentage" => 80.0
}, },
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100.0,
"name" => "/a", "name" => "/a",
"pageviews" => 1, "pageviews" => 1,
"time_on_page" => 10.0, "time_on_page" => 10,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 20.0
} }
] ]
end end
@ -2103,24 +2164,27 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100,
"comparison" => %{ "comparison" => %{
"bounce_rate" => 0, "bounce_rate" => 0.0,
"pageviews" => 0, "pageviews" => 0,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 0, "visitors" => 0,
"scroll_depth" => nil, "scroll_depth" => nil,
"percentage" => 0.0,
"change" => %{ "change" => %{
"bounce_rate" => nil, "bounce_rate" => nil,
"pageviews" => 100, "pageviews" => 100,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 100, "visitors" => 100,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 100
} }
}, },
"name" => "/page2", "name" => "/page2",
"pageviews" => 2, "pageviews" => 2,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 2, "visitors" => 2,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 66.67
}, },
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100,
@ -2129,18 +2193,21 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => nil, "scroll_depth" => nil,
"percentage" => 33.33,
"comparison" => %{ "comparison" => %{
"bounce_rate" => 100, "bounce_rate" => 100,
"pageviews" => 1, "pageviews" => 1,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => nil, "scroll_depth" => nil,
"percentage" => 100.0,
"change" => %{ "change" => %{
"bounce_rate" => 0, "bounce_rate" => 0,
"pageviews" => 0, "pageviews" => 0,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 0, "visitors" => 0,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => -67
} }
} }
} }
@ -2177,7 +2244,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 3, "pageviews" => 3,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 3, "visitors" => 3,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 50.0
}, },
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100,
@ -2185,7 +2253,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 2, "pageviews" => 2,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 2, "visitors" => 2,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 33.33
}, },
%{ %{
"bounce_rate" => 100, "bounce_rate" => 100,
@ -2193,7 +2262,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"pageviews" => 1, "pageviews" => 1,
"time_on_page" => nil, "time_on_page" => nil,
"visitors" => 1, "visitors" => 1,
"scroll_depth" => nil "scroll_depth" => nil,
"percentage" => 16.67
} }
] ]
end end
@ -2241,14 +2311,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visits" => 2, "visits" => 2,
"name" => "/page1", "name" => "/page1",
"visit_duration" => 0, "visit_duration" => 0,
"bounce_rate" => 100 "bounce_rate" => 100,
"percentage" => 66.67
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"visits" => 2, "visits" => 2,
"name" => "/page2", "name" => "/page2",
"visit_duration" => 450, "visit_duration" => 450,
"bounce_rate" => 50 "bounce_rate" => 50,
"percentage" => 33.33
} }
] ]
end end
@ -2299,14 +2371,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visits" => 1, "visits" => 1,
"name" => "/blog", "name" => "/blog",
"visit_duration" => 60, "visit_duration" => 60,
"bounce_rate" => 0 "bounce_rate" => 0,
"percentage" => 50.0
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"visits" => 1, "visits" => 1,
"name" => "/blog/john-2", "name" => "/blog/john-2",
"visit_duration" => 0, "visit_duration" => 0,
"bounce_rate" => 100 "bounce_rate" => 100,
"percentage" => 50.0
} }
] ]
end end
@ -2356,14 +2430,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visits" => 2, "visits" => 2,
"name" => "/page1", "name" => "/page1",
"visit_duration" => 0, "visit_duration" => 0,
"bounce_rate" => 100 "bounce_rate" => 100,
"percentage" => 66.67
}, },
%{ %{
"visitors" => 1, "visitors" => 1,
"visits" => 2, "visits" => 2,
"name" => "/page2", "name" => "/page2",
"visit_duration" => 450, "visit_duration" => 450,
"bounce_rate" => 50 "bounce_rate" => 50,
"percentage" => 33.33
} }
] ]
@ -2379,14 +2455,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visits" => 5, "visits" => 5,
"name" => "/page2", "name" => "/page2",
"visit_duration" => 240.0, "visit_duration" => 240.0,
"bounce_rate" => 20 "bounce_rate" => 20.0,
"percentage" => 60.0
}, },
%{ %{
"visitors" => 2, "visitors" => 2,
"visits" => 2, "visits" => 2,
"name" => "/page1", "name" => "/page1",
"visit_duration" => 0, "visit_duration" => 0.0,
"bounce_rate" => 100 "bounce_rate" => 100.0,
"percentage" => 40.0
} }
] ]
end end
@ -2444,14 +2522,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"visit_duration" => 0, "visit_duration" => 0,
"visitors" => 1, "visitors" => 1,
"visits" => 1, "visits" => 1,
"bounce_rate" => 100 "bounce_rate" => 100,
"percentage" => 50.0
}, },
%{ %{
"name" => "/page2", "name" => "/page2",
"visit_duration" => 0, "visit_duration" => 0,
"visitors" => 1, "visitors" => 1,
"visits" => 1, "visits" => 1,
"bounce_rate" => 100 "bounce_rate" => 100,
"percentage" => 50.0
} }
] ]
end end
@ -2610,21 +2690,24 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"name" => "/a", "name" => "/a",
"visits" => 10, "visits" => 10,
"visitors" => 6, "visitors" => 6,
"bounce_rate" => 10 "bounce_rate" => 10.0,
"percentage" => 66.67
}, },
%{ %{
"visit_duration" => 50.0, "visit_duration" => 50.0,
"name" => "/bbb", "name" => "/bbb",
"visits" => 2, "visits" => 2,
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 0 "bounce_rate" => 0.0,
"percentage" => 22.22
}, },
%{ %{
"visit_duration" => 0, "visit_duration" => 0,
"name" => "/aaa", "name" => "/aaa",
"visits" => 1, "visits" => 1,
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100 "bounce_rate" => 100.0,
"percentage" => 11.11
} }
] ]
end end
@ -2658,8 +2741,20 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") conn = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "/page1", "visitors" => 2, "visits" => 2, "exit_rate" => 66.7}, %{
%{"name" => "/page2", "visitors" => 1, "visits" => 1, "exit_rate" => 100} "name" => "/page1",
"visitors" => 2,
"visits" => 2,
"exit_rate" => 66.7,
"percentage" => 66.67
},
%{
"name" => "/page2",
"visitors" => 1,
"visits" => 1,
"exit_rate" => 100,
"percentage" => 33.33
}
] ]
end end
@ -2692,8 +2787,20 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "/page2", "visitors" => 1, "visits" => 1, "exit_rate" => 100}, %{
%{"name" => "/page1", "visitors" => 2, "visits" => 2, "exit_rate" => 66.7} "name" => "/page2",
"visitors" => 1,
"visits" => 1,
"exit_rate" => 100.0,
"percentage" => 33.33
},
%{
"name" => "/page1",
"visitors" => 2,
"visits" => 2,
"exit_rate" => 66.7,
"percentage" => 66.67
}
] ]
end end
@ -2740,7 +2847,7 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
# We're going to only join sessions where the entry hostname matches the filter # We're going to only join sessions where the entry hostname matches the filter
assert json_response(conn, 200)["results"] == assert json_response(conn, 200)["results"] ==
[%{"name" => "/page1", "visitors" => 1, "visits" => 1}] [%{"name" => "/page1", "visitors" => 1, "visits" => 1, "percentage" => 100.0}]
end end
test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do
@ -2778,7 +2885,7 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "/", "visitors" => 1, "visits" => 1} %{"name" => "/", "visitors" => 1, "visits" => 1, "percentage" => 100.0}
] ]
end end
@ -2822,8 +2929,20 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
conn1 = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") conn1 = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "/page1", "visitors" => 2, "visits" => 2, "exit_rate" => 66.7}, %{
%{"name" => "/page2", "visitors" => 1, "visits" => 1, "exit_rate" => 100} "name" => "/page1",
"visitors" => 2,
"visits" => 2,
"exit_rate" => 66.7,
"percentage" => 66.67
},
%{
"name" => "/page2",
"visitors" => 1,
"visits" => 1,
"exit_rate" => 100.0,
"percentage" => 33.33
}
] ]
conn2 = conn2 =
@ -2837,9 +2956,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"name" => "/page2", "name" => "/page2",
"visitors" => 3, "visitors" => 3,
"visits" => 4, "visits" => 4,
"exit_rate" => 80.0 "exit_rate" => 80.0,
"percentage" => 60.0
}, },
%{"name" => "/page1", "visitors" => 2, "visits" => 2, "exit_rate" => 66.7} %{
"name" => "/page1",
"visitors" => 2,
"visits" => 2,
"exit_rate" => 66.7,
"percentage" => 40.0
}
] ]
end end
@ -2938,8 +3064,8 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "/exit1", "visitors" => 1, "visits" => 1}, %{"name" => "/exit1", "visitors" => 1, "visits" => 1, "percentage" => 50.0},
%{"name" => "/exit2", "visitors" => 1, "visits" => 1} %{"name" => "/exit2", "visitors" => 1, "visits" => 1, "percentage" => 50.0}
] ]
end end
@ -2996,19 +3122,22 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
"exit_rate" => 50.0, "exit_rate" => 50.0,
"name" => "/a", "name" => "/a",
"visits" => 10, "visits" => 10,
"visitors" => 6 "visitors" => 6,
"percentage" => 66.67
}, },
%{ %{
"exit_rate" => 100.0, "exit_rate" => 100.0,
"name" => "/bbb", "name" => "/bbb",
"visits" => 2, "visits" => 2,
"visitors" => 2 "visitors" => 2,
"percentage" => 22.22
}, },
%{ %{
"exit_rate" => 100.0, "exit_rate" => 100.0,
"name" => "/aaa", "name" => "/aaa",
"visits" => 1, "visits" => 1,
"visitors" => 1 "visitors" => 1,
"percentage" => 11.11
} }
] ]
end end

View File

@ -38,8 +38,20 @@ defmodule PlausibleWeb.Api.StatsController.RegionsTest do
conn = get(conn, "/api/stats/#{site.domain}/regions?period=day") conn = get(conn, "/api/stats/#{site.domain}/regions?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => "EE-37", "country_flag" => "🇪🇪", "name" => "Harjumaa", "visitors" => 3}, %{
%{"code" => "EE-39", "country_flag" => "🇪🇪", "name" => "Hiiumaa", "visitors" => 2} "code" => "EE-37",
"country_flag" => "🇪🇪",
"name" => "Harjumaa",
"visitors" => 3,
"percentage" => 60.0
},
%{
"code" => "EE-39",
"country_flag" => "🇪🇪",
"name" => "Hiiumaa",
"visitors" => 2,
"percentage" => 40.0
}
] ]
end end
@ -48,7 +60,13 @@ defmodule PlausibleWeb.Api.StatsController.RegionsTest do
conn = get(conn, "/api/stats/#{site.domain}/regions?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/regions?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"code" => "EE-39", "country_flag" => "🇪🇪", "name" => "Hiiumaa", "visitors" => 2} %{
"code" => "EE-39",
"country_flag" => "🇪🇪",
"name" => "Hiiumaa",
"visitors" => 2,
"percentage" => 100.0
}
] ]
end end

View File

@ -14,8 +14,8 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do
conn = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day") conn = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Desktop", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Desktop", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Laptop", "visitors" => 1, "percentage" => 33.3} %{"name" => "Laptop", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -47,14 +47,14 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"visit_duration" => 300, "visit_duration" => 300,
"percentage" => 66.7 "percentage" => 66.67
}, },
%{ %{
"name" => "Laptop", "name" => "Laptop",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0, "visit_duration" => 0,
"percentage" => 33.3 "percentage" => 33.33
} }
] ]
end end
@ -212,8 +212,8 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do
conn1 = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day") conn1 = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "Desktop", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Desktop", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Laptop", "visitors" => 1, "percentage" => 33.3} %{"name" => "Laptop", "visitors" => 1, "percentage" => 33.33}
] ]
conn2 = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day&with_imported=true") conn2 = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day&with_imported=true")
@ -320,8 +320,8 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do
conn = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Desktop", "visitors" => 2, "percentage" => 66.7}, %{"name" => "Desktop", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Mobile", "visitors" => 1, "percentage" => 33.3} %{"name" => "Mobile", "visitors" => 1, "percentage" => 33.33}
] ]
end end

View File

@ -34,9 +34,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=day") conn = get(conn, "/api/stats/#{site.domain}/sources?period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 3}, %{"name" => "Google", "visitors" => 3, "percentage" => 50.0},
%{"name" => "DuckDuckGo", "visitors" => 2}, %{"name" => "DuckDuckGo", "visitors" => 2, "percentage" => 33.33},
%{"name" => "Direct / None", "visitors" => 1} %{"name" => "Direct / None", "visitors" => 1, "percentage" => 16.67}
] ]
end end
@ -84,8 +84,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
assert json_response(conn, 200)["meta"] == %{ assert json_response(conn, 200)["meta"] == %{
@ -142,8 +142,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -192,8 +192,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Facebook", "visitors" => 2}, %{"name" => "Facebook", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -246,8 +246,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -262,6 +262,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
]) ])
populate_stats(site, [ populate_stats(site, [
build(:imported_visitors),
build(:imported_visitors),
build(:imported_visitors),
build(:imported_sources, build(:imported_sources,
source: "Google", source: "Google",
visitors: 2 visitors: 2
@ -275,15 +278,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn1 = get(conn, "/api/stats/#{site.domain}/sources?period=day") conn1 = get(conn, "/api/stats/#{site.domain}/sources?period=day")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
conn2 = get(conn, "/api/stats/#{site.domain}/sources?period=day&with_imported=true") conn2 = get(conn, "/api/stats/#{site.domain}/sources?period=day&with_imported=true")
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{"name" => "Google", "visitors" => 4}, %{"name" => "Google", "visitors" => 4, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 2} %{"name" => "DuckDuckGo", "visitors" => 2, "percentage" => 33.33}
] ]
end end
@ -319,13 +322,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "DuckDuckGo", "name" => "DuckDuckGo",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 50.0
}, },
%{ %{
"name" => "Google", "name" => "Google",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 50.0
} }
] ]
end end
@ -355,6 +360,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
]) ])
populate_stats(site, [ populate_stats(site, [
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_visitors, date: ~D[2021-01-01]),
build(:imported_sources, build(:imported_sources,
source: "Google", source: "Google",
date: ~D[2021-01-01], date: ~D[2021-01-01],
@ -384,13 +392,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "DuckDuckGo", "name" => "DuckDuckGo",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 50.0
}, },
%{ %{
"name" => "Google", "name" => "Google",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 50.0
} }
] ]
@ -404,14 +414,16 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "Google", "name" => "Google",
"visitors" => 3, "visitors" => 3,
"bounce_rate" => 25, "bounce_rate" => 25.0,
"visit_duration" => 450.0 "visit_duration" => 450.0,
"percentage" => 60.0
}, },
%{ %{
"name" => "DuckDuckGo", "name" => "DuckDuckGo",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 50 "visit_duration" => 50.0,
"percentage" => 40.0
} }
] ]
end end
@ -438,8 +450,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=realtime") conn = get(conn, "/api/stats/#{site.domain}/sources?period=realtime")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -468,7 +480,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn1 = get(conn, "/api/stats/#{site.domain}/sources?period=day&limit=1&page=2") conn1 = get(conn, "/api/stats/#{site.domain}/sources?period=day&limit=1&page=2")
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
conn2 = conn2 =
@ -478,7 +490,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{"name" => "Google", "visitors" => 2} %{"name" => "Google", "visitors" => 2, "percentage" => 66.67}
] ]
end end
@ -502,8 +514,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -527,8 +539,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&filters=#{filters}") conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&filters=#{filters}")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "Google", "visitors" => 2}, %{"name" => "Google", "visitors" => 2, "percentage" => 66.67},
%{"name" => "DuckDuckGo", "visitors" => 1} %{"name" => "DuckDuckGo", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -543,9 +555,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn = get(conn, "/api/stats/#{site.domain}/sources?order_by=#{order_by}&period=day") conn = get(conn, "/api/stats/#{site.domain}/sources?order_by=#{order_by}&period=day")
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "C", "visitors" => 1}, %{"name" => "C", "visitors" => 1, "percentage" => 33.33},
%{"name" => "B", "visitors" => 1}, %{"name" => "B", "visitors" => 1, "percentage" => 33.33},
%{"name" => "A", "visitors" => 1} %{"name" => "A", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -593,10 +605,34 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn1, 200)["results"] == [ assert json_response(conn1, 200)["results"] == [
%{"name" => "Z", "visitors" => 1, "bounce_rate" => 100, "visit_duration" => 0}, %{
%{"name" => "A", "visitors" => 2, "bounce_rate" => 100, "visit_duration" => 0}, "name" => "Z",
%{"name" => "C", "visitors" => 1, "bounce_rate" => 0, "visit_duration" => 30}, "visitors" => 1,
%{"name" => "B", "visitors" => 1, "bounce_rate" => 0, "visit_duration" => 45} "bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 25.0
},
%{
"name" => "A",
"visitors" => 2,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 50.0
},
%{
"name" => "C",
"visitors" => 1,
"bounce_rate" => 0,
"visit_duration" => 30,
"percentage" => 25.0
},
%{
"name" => "B",
"visitors" => 1,
"bounce_rate" => 0,
"visit_duration" => 45,
"percentage" => 25.0
}
] ]
order_by_flipped = Jason.encode!([["visit_duration", "desc"], ["visit:source", "asc"]]) order_by_flipped = Jason.encode!([["visit_duration", "desc"], ["visit:source", "asc"]])
@ -608,10 +644,34 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn2, 200)["results"] == [ assert json_response(conn2, 200)["results"] == [
%{"name" => "B", "visitors" => 1, "bounce_rate" => 0, "visit_duration" => 45}, %{
%{"name" => "C", "visitors" => 1, "bounce_rate" => 0, "visit_duration" => 30}, "name" => "B",
%{"name" => "A", "visitors" => 2, "bounce_rate" => 100, "visit_duration" => 0}, "visitors" => 1,
%{"name" => "Z", "visitors" => 1, "bounce_rate" => 100, "visit_duration" => 0} "bounce_rate" => 0,
"visit_duration" => 45,
"percentage" => 25.0
},
%{
"name" => "C",
"visitors" => 1,
"bounce_rate" => 0,
"visit_duration" => 30,
"percentage" => 25.0
},
%{
"name" => "A",
"visitors" => 2,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 50.0
},
%{
"name" => "Z",
"visitors" => 1,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 25.0
}
] ]
end end
@ -651,11 +711,18 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0, "visit_duration" => 0,
"percentage" => 66.67,
"comparison" => %{ "comparison" => %{
"visitors" => 0, "visitors" => 0,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => nil, "visit_duration" => nil,
"change" => %{"visitors" => 100, "bounce_rate" => nil, "visit_duration" => nil} "percentage" => 0.0,
"change" => %{
"visitors" => 100,
"bounce_rate" => nil,
"visit_duration" => nil,
"percentage" => 100
}
} }
}, },
%{ %{
@ -663,11 +730,18 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0, "visit_duration" => 0,
"percentage" => 33.33,
"comparison" => %{ "comparison" => %{
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0, "visit_duration" => 0,
"change" => %{"visitors" => 0, "bounce_rate" => 0, "visit_duration" => 0} "percentage" => 100.0,
"change" => %{
"visitors" => 0,
"bounce_rate" => 0,
"visit_duration" => 0,
"percentage" => -67
}
} }
} }
] ]
@ -896,13 +970,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "Paid Social", "name" => "Paid Social",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "Organic Search", "name" => "Organic Search",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 33.33
} }
] ]
end end
@ -1087,13 +1163,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "email", "name" => "email",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 50.0
}, },
%{ %{
"name" => "social", "name" => "social",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 50.0
} }
] ]
@ -1108,13 +1186,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "email", "name" => "email",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"visit_duration" => 50 "visit_duration" => 50,
"percentage" => 50.0
}, },
%{ %{
"name" => "social", "name" => "social",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 50.0
} }
] ]
end end
@ -1167,7 +1247,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "social", "name" => "social",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 100.0
} }
] ]
@ -1181,8 +1262,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "social", "name" => "social",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 100.0
} }
] ]
end end
@ -1348,13 +1430,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "august", "name" => "august",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "profile", "name" => "profile",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 33.33
} }
] ]
@ -1369,13 +1453,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "august", "name" => "august",
"visitors" => 3, "visitors" => 3,
"bounce_rate" => 67, "bounce_rate" => 67,
"visit_duration" => 300 "visit_duration" => 300,
"percentage" => 60.0
}, },
%{ %{
"name" => "profile", "name" => "profile",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 40.0
} }
] ]
end end
@ -1432,7 +1518,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "profile", "name" => "profile",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 100.0
} }
] ]
@ -1446,8 +1533,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "profile", "name" => "profile",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 100.0
} }
] ]
end end
@ -1594,13 +1682,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "newsletter", "name" => "newsletter",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "Twitter", "name" => "Twitter",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 33.33
} }
] ]
end end
@ -1766,13 +1856,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "Sweden", "name" => "Sweden",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "oat milk", "name" => "oat milk",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 33.33
} }
] ]
@ -1786,14 +1878,16 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "Sweden", "name" => "Sweden",
"visitors" => 3, "visitors" => 3,
"bounce_rate" => 67, "bounce_rate" => 67.0,
"visit_duration" => 300 "visit_duration" => 300.0,
"percentage" => 60.0
}, },
%{ %{
"name" => "oat milk", "name" => "oat milk",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 40.0
} }
] ]
end end
@ -1850,7 +1944,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "oat milk", "name" => "oat milk",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 100.0
} }
] ]
@ -1864,8 +1959,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "oat milk", "name" => "oat milk",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 100.0
} }
] ]
end end
@ -2031,13 +2127,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "blog", "name" => "blog",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 100, "bounce_rate" => 100,
"visit_duration" => 0 "visit_duration" => 0,
"percentage" => 66.67
}, },
%{ %{
"name" => "ad", "name" => "ad",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 33.33
} }
] ]
@ -2051,14 +2149,16 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "blog", "name" => "blog",
"visitors" => 3, "visitors" => 3,
"bounce_rate" => 67, "bounce_rate" => 67.0,
"visit_duration" => 300 "visit_duration" => 300.0,
"percentage" => 60.0
}, },
%{ %{
"name" => "ad", "name" => "ad",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 40.0
} }
] ]
end end
@ -2115,7 +2215,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "ad", "name" => "ad",
"visitors" => 1, "visitors" => 1,
"bounce_rate" => 0, "bounce_rate" => 0,
"visit_duration" => 900 "visit_duration" => 900,
"percentage" => 100.0
} }
] ]
@ -2129,8 +2230,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
%{ %{
"name" => "ad", "name" => "ad",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50, "bounce_rate" => 50.0,
"visit_duration" => 800.0 "visit_duration" => 800.0,
"percentage" => 100.0
} }
] ]
end end
@ -2637,8 +2739,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "10words.com", "visitors" => 2}, %{"name" => "10words.com", "visitors" => 2, "percentage" => 66.67},
%{"name" => "10words.com/page1", "visitors" => 1} %{"name" => "10words.com/page1", "visitors" => 1, "percentage" => 33.33}
] ]
end end
@ -2683,7 +2785,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
) )
assert json_response(conn, 200)["results"] == [ assert json_response(conn, 200)["results"] == [
%{"name" => "example.com/page1", "visitors" => 1} %{"name" => "example.com/page1", "visitors" => 1, "percentage" => 100.0}
] ]
end end
@ -2724,7 +2826,8 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
"name" => "10words.com", "name" => "10words.com",
"visitors" => 2, "visitors" => 2,
"bounce_rate" => 50.0, "bounce_rate" => 50.0,
"visit_duration" => 450 "visit_duration" => 450,
"percentage" => 100.0
} }
] ]
end end

View File

@ -584,10 +584,10 @@ defmodule PlausibleWeb.StatsControllerTest do
assert result == [ assert result == [
["property", "value", "visitors", "events", "percentage"], ["property", "value", "visitors", "events", "percentage"],
["author", "(none)", "3", "4", "50.0"], ["author", "(none)", "3", "4", "50.0"],
["author", "uku", "2", "2", "33.3"], ["author", "uku", "2", "2", "33.33"],
["author", "marko", "1", "1", "16.7"], ["author", "marko", "1", "1", "16.67"],
["logged_in", "(none)", "5", "5", "83.3"], ["logged_in", "(none)", "5", "5", "83.33"],
["logged_in", "true", "1", "2", "16.7"], ["logged_in", "true", "1", "2", "16.67"],
[""] [""]
] ]
end end