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
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
### Removed
### Changed

View File

@ -90,6 +90,7 @@
--color-gray-950: var(--color-zinc-950);
/* 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-750: rgb(50 50 54);
--color-gray-825: rgb(35 35 38);
@ -294,16 +295,12 @@ blockquote {
display: inline;
}
.table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-100);
.table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-75);
}
.dark .table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-800);
}
.dark .table-striped tbody tr:nth-child(even) {
background-color: var(--color-gray-900);
.dark .table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-850);
}
.fade-enter {

View File

@ -32,33 +32,6 @@
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 {
from {
opacity: 0;

View File

@ -66,7 +66,7 @@ export const SearchInput = ({
type="text"
placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
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
)}
onChange={debouncedOnSearchInputChange}

View File

@ -15,14 +15,18 @@ export const SortButton = ({
return (
<button
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}
<span
title={next.hint}
className={classNames(
'absolute',
'rounded inline-block h-4 w-4',
'rounded inline-block size-4',
'ml-1',
{
[SortDirection.asc]: 'rotate-180',
@ -30,9 +34,8 @@ export const SortButton = ({
}[sortDirection ?? next.direction],
!sortDirection && 'opacity-0',
!sortDirection && 'group-hover:opacity-100',
sortDirection &&
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'transition'
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'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"
*/
renderValue?: (item: T) => ReactNode
renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
/** Function used to create richer cells */
renderItem?: (item: T) => ReactNode
}
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
return (
<th
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
)}
align={align}
@ -58,7 +58,13 @@ export const TableCell = ({
align?: 'left' | 'right'
}) => {
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}
</td>
)
@ -68,15 +74,42 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
rowIndex,
pageIndex,
item,
columns
columns,
tappedRowName,
onRowTap
}: {
rowIndex: number
pageIndex?: number
item: 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 (
<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 }) => (
<TableCell
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(item)
: renderValue
? renderValue(item)
? renderValue(item, isRowActive)
: (item[key] ?? '')}
</TableCell>
))}
@ -101,6 +134,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns: ColumnConfiguraton<T>[]
data: T[] | { pages: T[][] }
}) => {
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
if (column.metricWarning) {
return (
@ -125,13 +160,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
}
return (
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr className="text-xs font-bold text-gray-500 dark:text-gray-400">
<table className="border-collapse table-striped table-fixed w-max min-w-full">
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
{columns.map((column) => (
<TableHeaderCell
key={`header_${String(column.key)}`}
className={classNames('p-2 tracking-wide', column.width)}
className={classNames('p-2', column.width)}
align={column.align}
>
{column.onSort ? (
@ -156,6 +191,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns={columns}
rowIndex={rowIndex}
key={rowIndex}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
: data.pages.map((page, pageIndex) =>
@ -166,6 +203,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
rowIndex={rowIndex}
pageIndex={pageIndex}
key={`page_${pageIndex}_row_${rowIndex}`}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
)}

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export default function Bar({
return (
<div className="w-full h-full relative" style={style}>
<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}%` }}
></div>
{children}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,6 +37,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -54,7 +56,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
search: (search) => search
}}
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() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -93,7 +97,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
metrics={chooseMetrics()}
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
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() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -131,7 +137,7 @@ function Cities({ query, site, afterFetchData }) {
metrics={chooseMetrics()}
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
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() {
return (
<div>
<div className="group/report overflow-x-hidden">
<div className="w-full flex justify-between">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">

View File

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

View File

@ -11,12 +11,14 @@ import {
useRememberOrderBy
} from '../../hooks/use-order-by'
import { Metric } from '../reports/metrics'
import * as metricsModule from '../reports/metrics'
import { BreakdownResultMeta, DashboardQuery } from '../../query'
import { ColumnConfiguraton } from '../../components/table'
import { BreakdownTable } from './breakdown-table'
import { useSiteContext } from '../../site-context'
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
import { SharedReportProps } from '../reports/list'
import { hasConversionGoalFilter } from '../../util/filters'
export type ReportInfo = {
/** 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. */
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
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,
getExternalLinkUrl,
searchEnabled = true,
showPercentageColumn = false,
afterFetchData,
afterFetchNextPage,
addSearchFilter,
@ -71,20 +76,28 @@ export default function BreakdownModal<TListItem extends { name: string }>({
const { query } = useQueryContext()
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 defaultOrderBy = getStoredOrderBy({
domain: site.domain,
reportInfo,
metrics,
metrics: breakdownMetrics,
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
})
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics,
metrics: breakdownMetrics,
defaultOrderBy
})
useRememberOrderBy({
effectiveOrderBy: orderBy,
metrics,
metrics: breakdownMetrics,
reportInfo
})
const apiState = usePaginatedGetAPI<
@ -125,7 +138,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
{
label: reportInfo.dimensionLabel,
key: 'name',
width: 'w-48 md:w-full flex items-center break-all',
width: 'w-40 md:w-48',
align: 'left',
renderItem: (item) => (
<NameCell
@ -136,29 +149,39 @@ export default function BreakdownModal<TListItem extends { name: string }>({
/>
)
},
...metrics.map(
(m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query),
key: m.key,
width: m.width,
align: 'right',
metricWarning: getMetricWarning(m, meta),
renderValue: (item) => m.renderValue(item, meta),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
)
...breakdownMetrics
.filter((m) => showPercentageColumn || m.key !== 'percentage')
.map(
(m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query),
key: m.key,
width: m.width,
align: 'right',
metricWarning: getMetricWarning(m, meta),
renderValue: (item, isRowHovered) =>
m.renderValue(
showPercentageColumn && m.key === 'visitors'
? { ...item, percentage: null }
: item,
meta,
{ detailedView: true, isRowHovered }
),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
)
],
[
reportInfo.dimensionLabel,
metrics,
breakdownMetrics,
getFilterInfo,
query,
orderByDictionary,
toggleSortByMetric,
renderIcon,
getExternalLinkUrl,
meta
meta,
showPercentageColumn
]
)
@ -190,7 +213,7 @@ const NameCell = <TListItem extends { name: string }>({
renderIcon?: (item: TListItem) => ReactNode
getExternalLinkUrl?: (listItem: TListItem) => string
}) => (
<>
<div className="max-w-full break-all flex items-center">
{typeof renderIcon === 'function' && renderIcon(item)}
<DrilldownLink
path={rootRoute.path}
@ -203,7 +226,7 @@ const NameCell = <TListItem extends { name: string }>({
{typeof getExternalLinkUrl === 'function' && (
<ExternalLinkIcon url={getExternalLinkUrl(item)} />
)}
</>
</div>
)
const ExternalLinkIcon = ({ url }: { url?: string }) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import React from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { useParams } from 'react-router-dom'
import Modal from './modal'
@ -68,6 +69,7 @@ class FilterModal extends React.Component {
)
this.handleKeydown = this.handleKeydown.bind(this)
this.closeModal = this.closeModal.bind(this)
this.state = {
query,
filterState,
@ -108,6 +110,13 @@ class FilterModal extends React.Component {
)
}
closeModal() {
this.props.navigate({
path: rootRoute.path,
search: (search) => search
})
}
selectFiltersAndCloseModal(filters) {
this.props.navigate({
path: rootRoute.path,
@ -169,13 +178,23 @@ class FilterModal extends React.Component {
render() {
return (
<Modal maxWidth="460px">
<h1 className="text-xl font-bold dark:text-gray-100">
Filter by {formatFilterGroup(this.props.modalType)}
</h1>
<Modal maxWidth="460px" onClose={this.closeModal}>
<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)}
</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>
<main className="modal__content">
<div className="mt-2 md:mt-4 border-b border-gray-300 dark:border-gray-700"></div>
<main>
<form
className="flex flex-col"
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
type="submit"
className="button !px-3"

View File

@ -12,21 +12,21 @@ import { SortDirection } from '../../hooks/use-order-by'
const VIEWS = {
countries: {
title: 'Top Countries',
title: 'Top countries',
dimension: 'country',
endpoint: '/countries',
dimensionLabel: 'Country',
defaultOrder: ['visitors', SortDirection.desc]
},
regions: {
title: 'Top Regions',
title: 'Top regions',
dimension: 'region',
endpoint: '/regions',
dimensionLabel: 'Region',
defaultOrder: ['visitors', SortDirection.desc]
},
cities: {
title: 'Top Cities',
title: 'Top cities',
dimension: 'city',
endpoint: '/cities',
dimensionLabel: 'City',
@ -88,7 +88,7 @@ function LocationsModal({ currentView }) {
return [
metrics.createVisitors({
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 { rootRoute } from '../../router'
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.
const DEFAULT_WIDTH = 1080
class Modal extends React.Component {
constructor(props) {
super(props)
@ -27,26 +23,21 @@ class Modal extends React.Component {
window.addEventListener('resize', this.handleResize, false)
this.handleResize()
}
componentWillUnmount() {
document.body.style.overflow = null
document.body.style.height = null
document.removeEventListener('mousedown', this.handleClickOutside)
window.removeEventListener('resize', this.handleResize, false)
}
handleClickOutside(e) {
if (this.node.current.contains(e.target)) {
return
}
this.props.onClose()
}
handleResize() {
this.setState({ viewport: window.innerWidth })
}
/**
* @description
* Decide whether to set max-width, and if so, to what.
@ -56,12 +47,11 @@ class Modal extends React.Component {
*/
getStyle() {
const { maxWidth } = this.props
const { viewport } = this.state
const styleObject = {}
if (maxWidth) {
styleObject.maxWidth = maxWidth
} else {
styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px'
styleObject.maxWidth = '880px'
}
return styleObject
}
@ -78,16 +68,17 @@ class Modal extends React.Component {
/>
<div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay">
<button className="modal__close"></button>
<div
ref={this.node}
className="modal__container dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
<FocusOnMount focusableRef={this.node} />
{this.props.children}
<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
ref={this.node}
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()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
<FocusOnMount focusableRef={this.node} />
{this.props.children}
</div>
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@ function PagesModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: 'Top Pages',
title: 'Top pages',
dimension: 'page',
endpoint: url.apiPath(site, '/pages'),
dimensionLabel: 'Page url',
@ -67,7 +67,7 @@ function PagesModal() {
return [
metrics.createVisitors({
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 reportInfo = {
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'),
dimension: propKey,
endpoint: url.apiPath(
site,
@ -71,6 +71,7 @@ function PropsModal() {
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
showPercentageColumn
/>
</Modal>
)

View File

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

View File

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

View File

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

View File

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

View File

@ -15,24 +15,22 @@ export function ChangeArrow({
className: string
hideNumber?: boolean
}) {
const formattedChange = hideNumber
? null
: ` ${numberShortFormatter(Math.abs(change))}%`
let icon = null
const arrowClassName = classNames(
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) {
icon = <ArrowUpRightIcon className={arrowClassName} />
} else if (change < 0) {
icon = <ArrowDownRightIcon className={arrowClassName} />
} else if (change === 0 && !hideNumber) {
icon = <>&#12336;</>
}
const formattedChange = hideNumber
? null
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
return (
<span className={className} data-testid="change-arrow">
{icon}

View File

@ -26,27 +26,34 @@ const COL_MIN_WIDTH = 70
function ExternalLink<T>({
item,
getExternalLinkUrl
getExternalLinkUrl,
isTapped
}: {
item: T
getExternalLinkUrl?: (item: T) => string
isTapped?: boolean
}) {
const dest = getExternalLinkUrl && getExternalLinkUrl(item)
if (dest) {
const className = isTapped
? 'visible md:invisible md:group-hover/row:visible'
: 'invisible md:group-hover/row:visible'
return (
<a
target="_blank"
rel="noreferrer"
href={dest}
className="w-4 h-4 invisible group-hover:visible"
>
<a target="_blank" rel="noreferrer" href={dest} className={className}>
<svg
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="none"
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 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>
<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>
</a>
)
@ -88,11 +95,6 @@ type ListReportProps = {
colMinWidth?: number
/** Navigation props to be passed to "More" link, if any. */
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. */
onClick?: () => void
/** Color of the comparison bars in light-mode. */
@ -114,7 +116,6 @@ export default function ListReport<
colMinWidth = COL_MIN_WIDTH,
afterFetchData,
detailsLinkProps,
maybeHideDetails,
onClick,
color,
getFilterInfo,
@ -129,6 +130,7 @@ export default function ListReport<
meta: BreakdownResultMeta | null
}>({ loading: true, list: null, meta: null })
const [visible, setVisible] = useState(false)
const [tappedRow, setTappedRow] = useState<string | null>(null)
const isRealtime = isRealTimeDashboard(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() {
if (state.list && state.list.length > 0) {
return (
@ -206,16 +240,14 @@ export default function ListReport<
</FlipMove>
</div>
{!!detailsLinkProps &&
!state.loading &&
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
<MoreLink
onClick={undefined}
className={'mt-2'}
linkProps={detailsLinkProps}
list={state.list}
/>
)}
{!!detailsLinkProps && !state.loading && (
<MoreLink
onClick={undefined}
className={'mt-3'}
linkProps={detailsLinkProps}
list={state.list}
/>
)}
</div>
)
}
@ -223,20 +255,22 @@ export default function ListReport<
}
function renderReportHeader() {
const metricLabels = getAvailableMetrics().map((metric) => {
return (
<div
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metric.renderLabel(query)}
</div>
)
})
const metricLabels = getAvailableMetrics()
.filter((metric) => !metric.meta.showOnHover)
.map((metric) => {
return (
<div
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metric.renderLabel(query)}
</div>
)
})
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>
{metricLabels}
</div>
@ -244,11 +278,22 @@ export default function ListReport<
}
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 (
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
<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 }}
onClick={handleRowClick}
>
{renderBarFor(listItem)}
{renderMetricValuesFor(listItem)}
@ -258,7 +303,7 @@ export default function ListReport<
}
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
return (
@ -267,10 +312,10 @@ export default function ListReport<
maxWidthDeduction={undefined}
count={listItem[metricToPlot]}
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}
>
<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
filterInfo={getFilterInfo(listItem)}
onClick={onClick}
@ -285,6 +330,7 @@ export default function ListReport<
<ExternalLink
item={listItem}
getExternalLinkUrl={getExternalLinkUrl}
isTapped={tappedRow === listItem.name}
/>
</div>
</Bar>
@ -299,19 +345,36 @@ export default function ListReport<
}
function renderMetricValuesFor(listItem: TListItem) {
return getAvailableMetrics().map((metric) => {
return (
<div
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span className="font-medium text-sm dark:text-gray-200 text-right">
{metric.renderValue(listItem, state.meta)}
</span>
</div>
)
})
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 (
<div
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span
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>
</div>
)
})}
</>
)
}
function renderLoading() {

View File

@ -11,10 +11,9 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
describe('single value', () => {
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.getByRole('tooltip')).toHaveTextContent('10')
})
it('renders large value', async () => {
@ -25,23 +24,19 @@ describe('single value', () => {
})
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.getByRole('tooltip')).toHaveTextContent('5.3%')
})
it('renders durations', async () => {
await renderWithTooltip(
<MetricValue {...valueProps('visit_duration', 60)} />
)
render(<MetricValue {...valueProps('visit_duration', 60)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
})
it('renders with custom formatter', async () => {
await renderWithTooltip(
render(
<MetricValue
{...valueProps('test_money', 5.3)}
formatter={(value) => `${value}$`}
@ -49,7 +44,6 @@ describe('single value', () => {
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
})
it('renders revenue properly', async () => {
@ -80,9 +74,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 visitors',
'↑ 100%',
'01 Aug - 31 Aug',
'vs',
'↑ 100%',
'5 visitors',
'01 July - 31 July'
].join('')
@ -98,9 +91,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'5 visitors',
'↓ 50%',
'01 Aug - 31 Aug',
'vs',
'↓ 50%',
'10 visitors',
'01 July - 31 July'
].join('')
@ -116,9 +108,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 visitors',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'0%',
'10 visitors',
'01 July - 31 July'
].join('')
@ -136,9 +127,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 conversions',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'0%',
'10 conversions',
'01 July - 31 July'
].join('')
@ -154,14 +144,7 @@ describe('comparisons', () => {
)
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10% ',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'10% ',
'01 July - 31 July'
].join('')
['10% ', '01 Aug - 31 Aug', '0%', '10% ', '01 July - 31 July'].join('')
)
})
@ -177,9 +160,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10$ test',
'↑ 100%',
'01 Aug - 31 Aug',
'vs',
'↑ 100%',
'5$ test',
'01 July - 31 July'
].join('')
@ -200,9 +182,8 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'$1,659.50 average_revenue',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'0%',
'$1,659.50 average_revenue',
'01 July - 31 July'
].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 { Tooltip } from '../../util/tooltip'
import { ChangeArrow } from './change-arrow'
@ -36,23 +36,84 @@ export default function MetricValue(props: {
renderLabel: (query: DashboardQuery) => string
formatter?: (value: ValueType) => string
meta: BreakdownResultMeta | null
detailedView?: boolean
isRowHovered?: boolean
}) {
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(
() => valueRenderProps(listItem, metric),
[listItem, metric]
)
const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
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)) {
return <span data-testid="metric-value">{shortFormatter(value)}</span>
return <span data-testid="metric-value">{displayFormatter(value)}</span>
}
const valueContent = (
<span
className={showTooltip ? 'cursor-default' : ''}
data-testid="metric-value"
>
{percentageDisplay && (
<span className="mr-3 text-gray-500 dark:text-gray-400">
{percentageDisplay}
</span>
)}
{displayFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
metric={metric}
className="inline-block pl-1 w-4"
hideNumber
/>
) : null}
</span>
)
if (!showTooltip) {
return valueContent
}
return (
<Tooltip
containerRef={portalRef as React.RefObject<HTMLElement>}
info={
<ComparisonTooltipContent
value={value}
@ -62,17 +123,7 @@ export default function MetricValue(props: {
/>
}
>
<span className="cursor-default" data-testid="metric-value">
{shortFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
metric={metric}
className="inline-block pl-1 w-4"
hideNumber
/>
) : null}
</span>
{valueContent}
</Tooltip>
)
}
@ -106,34 +157,34 @@ function ComparisonTooltipContent({
return (
<div className="text-left whitespace-nowrap py-1 space-y-2">
<div>
<div className="flex items-center">
<span className="font-bold text-base">
{longFormatter(value)} {label}
</span>
<div className="flex gap-x-4">
<div className="flex flex-col">
<span className="font-medium text-sm/6 text-white">
{longFormatter(value)} {label}
</span>
<div className="font-normal text-xs text-white">
{meta.date_range_label}
</div>
</div>
<ChangeArrow
metric={metric}
change={comparison.change}
className="pl-4 text-xs text-gray-100"
className="text-xs/6 font-medium text-white"
/>
</div>
<div className="font-normal text-xs">{meta.date_range_label}</div>
</div>
<div>vs</div>
<div className="w-full border-t border-gray-600"></div>
<div>
<div className="font-bold text-base">
<div className="font-medium text-sm/6 text-gray-300/80">
{longFormatter(comparison.value)} {label}
</div>
<div className="font-normal text-xs">
<div className="font-normal text-xs text-gray-300/80">
{meta.comparison_date_range_label}
</div>
</div>
</div>
)
} else {
return (
<div className="whitespace-nowrap">
{longFormatter(value)} {label}
</div>
)
return <div className="whitespace-nowrap">{longFormatter(value)}</div>
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -69,7 +69,11 @@ export function durationFormatter(duration: number): string {
export function percentageFormatter(number: number | null): string {
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 {
return '-'
}

View File

@ -26,16 +26,14 @@ export function Tooltip({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
)
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top',
modifiers: [
{ name: 'arrow', options: { element: arrowElement } },
{
name: 'offset',
options: {
offset: [0, 4]
offset: [0, 6]
}
},
...(boundary
@ -67,8 +65,6 @@ export function Tooltip({
popperStyle={styles.popper}
popperAttributes={attributes.popper}
setPopperElement={setPopperElement}
setArrowElement={setArrowElement}
arrowStyle={styles.arrow}
>
{info}
</TooltipMessage>
@ -82,16 +78,12 @@ function TooltipMessage({
popperStyle,
popperAttributes,
setPopperElement,
setArrowElement,
arrowStyle,
children
}: {
containerRef?: RefObject<HTMLElement>
popperStyle: CSSProperties
arrowStyle: CSSProperties
popperAttributes?: Record<string, string>
setPopperElement: (element: HTMLDivElement) => void
setArrowElement: (element: HTMLDivElement) => void
children: ReactNode
}) {
const messageElement = (
@ -99,15 +91,10 @@ function TooltipMessage({
ref={setPopperElement}
style={popperStyle}
{...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"
>
{children}
<div
ref={setArrowElement}
style={arrowStyle}
className="tooltip-arrow"
></div>
</div>
)
if (containerRef) {

View File

@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
phx-target="#funnel-form"
phx-click-away="cancel-add-funnel"
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">
{if @funnel, do: "Edit", else: "Add"} funnel

View File

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

View File

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

View File

@ -163,7 +163,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
def render(assigns) do
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
]

View File

@ -280,7 +280,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
},
%{
"dimensions" => ["Firefox"],
"metrics" => [2, 33.3],
"metrics" => [2, 33.33],
"comparison" => %{
"dimensions" => ["Firefox"],
"metrics" => [4, 50.0],
@ -304,11 +304,11 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
assert json_response(conn2, 200)["results"] == [
%{
"dimensions" => ["Safari"],
"metrics" => [1, 16.7],
"metrics" => [1, 16.67],
"comparison" => %{
"dimensions" => ["Safari"],
"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"] == [
%{
"dimensions" => ["Chrome"],
"metrics" => [2, 66.7],
"metrics" => [2, 66.67],
"comparison" => %{
"dimensions" => ["Chrome"],
"metrics" => [40, 40.0],
@ -370,7 +370,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryComparisonsTest do
},
%{
"dimensions" => ["Firefox"],
"metrics" => [1, 33.3],
"metrics" => [1, 33.33],
"comparison" => %{
"dimensions" => ["Firefox"],
"metrics" => [50, 50.0],

View File

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

View File

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

View File

@ -38,8 +38,20 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day")
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
@ -48,7 +60,13 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&filters=#{filters}")
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
@ -62,8 +80,20 @@ defmodule PlausibleWeb.Api.StatsController.CitiesTest do
conn = get(conn, "/api/stats/#{site.domain}/cities?period=day&with_imported=true")
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

View File

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

View File

@ -89,13 +89,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2,
"name" => "K2sna Kalle",
"events" => 2,
"percentage" => 66.7
"percentage" => 66.67
},
%{
"visitors" => 1,
"name" => "(none)",
"events" => 1,
"percentage" => 33.3
"percentage" => 33.33
}
]
end
@ -135,7 +135,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2,
"name" => "Teet",
"events" => 2,
"percentage" => 33.3
"percentage" => 33.33
}
]
@ -144,7 +144,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 1,
"name" => "(none)",
"events" => 1,
"percentage" => 16.7
"percentage" => 16.67
}
]
end
@ -1082,13 +1082,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2,
"name" => "K2sna Kalle",
"events" => 2,
"percentage" => 66.7
"percentage" => 66.67
},
%{
"visitors" => 1,
"name" => "Sipsik",
"events" => 1,
"percentage" => 33.3
"percentage" => 33.33
}
]
end
@ -1121,13 +1121,13 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
"visitors" => 2,
"name" => "bar",
"events" => 2,
"percentage" => 66.7
"percentage" => 66.67
},
%{
"visitors" => 1,
"name" => "foobar",
"events" => 1,
"percentage" => 33.3
"percentage" => 33.33
}
]
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(
[
%{
@ -280,11 +289,11 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
|> Enum.sort()
assert results == [
%{"name" => "A Nice Newsletter", "visitors" => 1},
%{"name" => "Direct / None", "visitors" => 1},
%{"name" => "DuckDuckGo", "visitors" => 2},
%{"name" => "Google", "visitors" => 4},
%{"name" => "Twitter", "visitors" => 1}
%{"name" => "A Nice Newsletter", "visitors" => 1, "percentage" => 11.11},
%{"name" => "Direct / None", "visitors" => 1, "percentage" => 11.11},
%{"name" => "DuckDuckGo", "visitors" => 2, "percentage" => 22.22},
%{"name" => "Google", "visitors" => 4, "percentage" => 44.44},
%{"name" => "Twitter", "visitors" => 1, "percentage" => 11.11}
]
end
@ -415,10 +424,10 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
|> Enum.sort()
assert results == [
%{"name" => "(not set)", "visitors" => 1},
%{"name" => "Direct", "visitors" => 2},
%{"name" => "Organic Search", "visitors" => 3},
%{"name" => "Paid Search", "visitors" => 2}
%{"name" => "(not set)", "visitors" => 1, "percentage" => 33.33},
%{"name" => "Direct", "visitors" => 2, "percentage" => 66.67},
%{"name" => "Organic Search", "visitors" => 3, "percentage" => 100.0},
%{"name" => "Paid Search", "visitors" => 2, "percentage" => 66.67}
]
end
@ -492,8 +501,9 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
%{
"bounce_rate" => 100.0,
"name" => "social",
"visit_duration" => 20,
"visitors" => 3
"visit_duration" => 20.0,
"visitors" => 3,
"percentage" => 100.0
}
]
end
@ -581,13 +591,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "august",
"visitors" => 2,
"bounce_rate" => 50.0,
"visit_duration" => 50.0
"visit_duration" => 50.0,
"percentage" => 50.0
},
%{
"name" => "profile",
"visitors" => 2,
"bounce_rate" => 100.0,
"visit_duration" => 50.0
"visit_duration" => 50.0,
"percentage" => 50.0
}
]
end
@ -676,13 +688,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "Sweden",
"visitors" => 3,
"bounce_rate" => 67.0,
"visit_duration" => 33.0
"visit_duration" => 33.0,
"percentage" => 60.0
},
%{
"name" => "oat milk",
"visitors" => 2,
"bounce_rate" => 100.0,
"visit_duration" => 50.0
"visit_duration" => 50.0,
"percentage" => 40.0
}
]
end
@ -770,13 +784,15 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"name" => "ad",
"visitors" => 2,
"bounce_rate" => 100.0,
"visit_duration" => 50.0
"visit_duration" => 50.0,
"percentage" => 50.0
},
%{
"name" => "blog",
"visitors" => 2,
"bounce_rate" => 50.0,
"visit_duration" => 50.0
"visit_duration" => 50.0,
"percentage" => 50.0
}
]
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(
[
%{
@ -877,12 +900,13 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
assert json_response(conn, 200)["results"] == [
%{
"bounce_rate" => 0,
"bounce_rate" => 0.0,
"time_on_page" => 60,
"visitors" => 3,
"pageviews" => 4,
"scroll_depth" => nil,
"name" => "/some-other-page"
"name" => "/some-other-page",
"percentage" => 60.0
},
%{
"bounce_rate" => 25.0,
@ -890,7 +914,8 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
"visitors" => 2,
"pageviews" => 2,
"scroll_depth" => nil,
"name" => "/"
"name" => "/",
"percentage" => 40.0
}
]
end
@ -960,12 +985,19 @@ defmodule PlausibleWeb.Api.StatsController.ImportedTest do
)
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,
"name" => "Edinburgh",
"visitors" => 1,
"country_flag" => "🇬🇧"
"country_flag" => "🇬🇧",
"percentage" => 50.0
}
]
end

View File

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

View File

@ -38,8 +38,20 @@ defmodule PlausibleWeb.Api.StatsController.RegionsTest do
conn = get(conn, "/api/stats/#{site.domain}/regions?period=day")
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
@ -48,7 +60,13 @@ defmodule PlausibleWeb.Api.StatsController.RegionsTest do
conn = get(conn, "/api/stats/#{site.domain}/regions?period=day&filters=#{filters}")
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

View File

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

View File

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

View File

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