Add Details modals for all Devices reports (#4419)

* return concatenated browser names and versions directly from the API

* return concatenated os names and versions directly from the API

* add detailed views for all device reports

* extract put_combined_name_with_version function

* return only version under the name key when detailed=true

* update changelog

* add more metrics into detailed views of Devices reports

* split up different devices modals into separate files
This commit is contained in:
RobertJoonas 2024-08-02 15:50:22 +03:00 committed by GitHub
parent 21372b2a7b
commit 1f8662438d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 549 additions and 71 deletions

View File

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- Support contains filter for goals
- UI to edit funnels
- Add Details views for browsers, browser versions, os-s, os versions, and screen sizes reports
- Add a search functionality in all Details views, except for Countries, Regions, and Cities
- Icons for browsers plausible/analytics#4239
- Automatic custom property selection in the dashboard Properties report

View File

@ -10,6 +10,11 @@ import PagesModal from './stats/modals/pages'
import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages'
import LocationsModal from './stats/modals/locations-modal'
import BrowsersModal from './stats/modals/devices/browsers-modal'
import BrowserVersionsModal from './stats/modals/devices/browser-versions-modal'
import OperatingSystemsModal from './stats/modals/devices/operating-systems-modal'
import OperatingSystemVersionsModal from './stats/modals/devices/operating-system-versions-modal'
import ScreenSizesModal from './stats/modals/devices/screen-sizes'
import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
@ -106,6 +111,31 @@ export const citiesRoute = {
element: <LocationsModal currentView="cities" />
}
export const browsersRoute = {
path: 'browsers',
element: <BrowsersModal />
}
export const browserVersionsRoute = {
path: 'browser-versions',
element: <BrowserVersionsModal />
}
export const operatingSystemsRoute = {
path: 'operating-systems',
element: <OperatingSystemsModal />
}
export const operatingSystemVersionsRoute = {
path: 'operating-system-versions',
element: <OperatingSystemVersionsModal />
}
export const screenSizesRoute = {
path: 'screen-sizes',
element: <ScreenSizesModal />
}
export const conversionsRoute = {
path: 'conversions',
element: <ConversionsModal />
@ -150,6 +180,11 @@ export function createAppRouter(site) {
countriesRoute,
regionsRoute,
citiesRoute,
browsersRoute,
browserVersionsRoute,
operatingSystemsRoute,
operatingSystemVersionsRoute,
screenSizesRoute,
conversionsRoute,
customPropsRoute,
filterRoute,

View File

@ -8,6 +8,13 @@ import * as url from '../../util/url';
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
import { useQueryContext } from '../../query-context';
import { useSiteContext } from '../../site-context';
import {
browsersRoute,
browserVersionsRoute,
operatingSystemsRoute,
operatingSystemVersionsRoute,
screenSizesRoute
} from '../../router';
// Icons copied from https://github.com/alrra/browser-logos
const BROWSER_ICONS = {
@ -31,7 +38,7 @@ const BROWSER_ICONS = {
'vivo Browser': 'vivo.png'
}
function browserIconFor(browser) {
export function browserIconFor(browser) {
const filename = BROWSER_ICONS[browser] || 'fallback.svg'
return (
@ -76,6 +83,7 @@ function Browsers({ afterFetchData }) {
keyLabel="Browser"
metrics={chooseMetrics()}
renderIcon={renderIcon}
detailsLinkProps={{ path: browsersRoute.path, search: (search) => search }}
/>
)
}
@ -85,13 +93,6 @@ function BrowserVersions({ afterFetchData }) {
const site = useSiteContext();
function fetchData() {
return api.get(url.apiPath(site, '/browser-versions'), query)
.then(res => {
return {
...res, results: res.results.map((row => {
return { ...row, name: `${row.browser} ${row.name}`, version: row.name }
}))
}
})
}
function renderIcon(listItem) {
@ -124,6 +125,7 @@ function BrowserVersions({ afterFetchData }) {
keyLabel="Browser version"
metrics={chooseMetrics()}
renderIcon={renderIcon}
detailsLinkProps={{ path: browserVersionsRoute.path, search: (search) => search }}
/>
)
}
@ -148,7 +150,7 @@ const OS_ICONS = {
'FreeBSD': 'freebsd.png',
}
function osIconFor(os) {
export function osIconFor(os) {
const filename = OS_ICONS[os] || 'fallback.svg'
return (
@ -193,6 +195,7 @@ function OperatingSystems({ afterFetchData }) {
renderIcon={renderIcon}
keyLabel="Operating system"
metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemsRoute.path, search: (search) => search }}
/>
)
}
@ -203,13 +206,6 @@ function OperatingSystemVersions({ afterFetchData }) {
function fetchData() {
return api.get(url.apiPath(site, '/operating-system-versions'), query)
.then(res => {
return {
...res, results: res.results.map((row => {
return { ...row, name: `${row.os} ${row.name}`, version: row.name }
}))
}
})
}
function renderIcon(listItem) {
@ -242,6 +238,7 @@ function OperatingSystemVersions({ afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel="Operating System Version"
metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemVersionsRoute.path, search: (search) => search }}
/>
)
@ -255,10 +252,8 @@ function ScreenSizes({ afterFetchData }) {
return api.get(url.apiPath(site, '/screen-sizes'), query)
}
function renderIcon(screenSize) {
return (
<span className="mr-1.5">{iconFor(screenSize.name)}</span>
)
function renderIcon(listItem) {
return screenSizeIconFor(listItem.name)
}
function getFilterFor(listItem) {
@ -284,30 +279,25 @@ function ScreenSizes({ afterFetchData }) {
keyLabel="Screen size"
metrics={chooseMetrics()}
renderIcon={renderIcon}
detailsLinkProps={{ path: screenSizesRoute.path, search: (search) => search }}
/>
)
}
function iconFor(screenSize) {
export function screenSizeIconFor(screenSize) {
let svg = null
if (screenSize === 'Mobile') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
)
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
} else if (screenSize === 'Tablet') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="4" y="2" width="16" height="20" rx="2" ry="2" transform="rotate(180 12 12)" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
)
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="4" y="2" width="16" height="20" rx="2" ry="2" transform="rotate(180 12 12)" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
} else if (screenSize === 'Laptop') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="2" y1="20" x2="22" y2="20" /></svg>
)
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="2" y1="20" x2="22" y2="20" /></svg>
} else if (screenSize === 'Desktop') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>
)
} else if (screenSize === '(not set)') {
return null
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>
}
return <span className="mr-1.5">{svg}</span>
}
export default function Devices() {

View File

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import Modal from './../modal';
import { addFilter } from '../../../query'
import BreakdownModal from "./../breakdown-modal";
import * as url from '../../../util/url';
import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { browserIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
function BrowserVersionsModal() {
const { query } = useQueryContext();
const site = useSiteContext();
const reportInfo = {
title: 'Browser Versions',
dimension: 'browser_version',
endpoint: url.apiPath(site, '/browser-versions'),
dimensionLabel: 'Browser version'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [reportInfo.dimension])
const renderIcon = useCallback((listItem) => browserIconFor(listItem.browser), [])
return (
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}
/>
</Modal>
)
}
export default BrowserVersionsModal

View File

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import Modal from './../modal';
import { addFilter } from '../../../query'
import BreakdownModal from "./../breakdown-modal";
import * as url from '../../../util/url';
import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { browserIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
function BrowsersModal() {
const { query } = useQueryContext();
const site = useSiteContext();
const reportInfo = {
title: 'Browsers',
dimension: 'browser',
endpoint: url.apiPath(site, '/browsers'),
dimensionLabel: 'Browser'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [reportInfo.dimension])
const renderIcon = useCallback((listItem) => browserIconFor(listItem.name), [])
return (
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}
/>
</Modal>
)
}
export default BrowsersModal

View File

@ -0,0 +1,26 @@
import { hasGoalFilter, isRealTimeDashboard } from "../../../util/filters";
import * as metrics from '../../reports/metrics'
export default function chooseMetrics(query) {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }),
metrics.createPercentage()
]
}
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors" }),
metrics.createPercentage(),
metrics.createBounceRate(),
metrics.createVisitDuration()
]
}

View File

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import Modal from './../modal';
import { addFilter } from '../../../query'
import BreakdownModal from "./../breakdown-modal";
import * as url from '../../../util/url';
import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { osIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
function OperatingSystemVersionsModal() {
const { query } = useQueryContext();
const site = useSiteContext();
const reportInfo = {
title: 'Operating System Versions',
dimension: 'os_version',
endpoint: url.apiPath(site, '/operating-system-versions'),
dimensionLabel: 'Operating system version'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [reportInfo.dimension])
const renderIcon = useCallback((listItem) => osIconFor(listItem.os), [])
return (
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}
/>
</Modal>
)
}
export default OperatingSystemVersionsModal

View File

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import Modal from './../modal';
import { addFilter } from '../../../query'
import BreakdownModal from "./../breakdown-modal";
import * as url from '../../../util/url';
import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { osIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
function OperatingSystemsModal() {
const { query } = useQueryContext();
const site = useSiteContext();
const reportInfo = {
title: 'Operating Systems',
dimension: 'os',
endpoint: url.apiPath(site, '/operating-systems'),
dimensionLabel: 'Operating system'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [reportInfo.dimension])
const renderIcon = useCallback((listItem) => osIconFor(listItem.name), [])
return (
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}
/>
</Modal>
)
}
export default OperatingSystemsModal

View File

@ -0,0 +1,43 @@
import React, { useCallback } from "react";
import Modal from './../modal';
import BreakdownModal from "./../breakdown-modal";
import * as url from '../../../util/url';
import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { screenSizeIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
function ScreenSizesModal() {
const { query } = useQueryContext();
const site = useSiteContext();
const reportInfo = {
title: 'Screen Sizes',
dimension: 'screen',
endpoint: url.apiPath(site, '/screen-sizes'),
dimensionLabel: 'Screen size'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
const renderIcon = useCallback((listItem) => screenSizeIconFor(listItem.name), [])
return (
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
searchEnabled={false}
renderIcon={renderIcon}
/>
</Modal>
)
}
export default ScreenSizesModal

View File

@ -12,6 +12,7 @@ defmodule PlausibleWeb.Api.StatsController do
require Logger
@revenue_metrics on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@not_set "(not set)"
plug(:date_validation_plug)
@ -1095,7 +1096,11 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:browser")
query = Query.from(site, params)
pagination = parse_pagination(params)
metrics = breakdown_metrics(query, [:percentage])
extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: []
metrics = breakdown_metrics(query, extra_metrics ++ [:percentage])
browsers =
Stats.breakdown(site, query, metrics, pagination)
@ -1122,29 +1127,36 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:browser_version")
query = Query.from(site, params)
pagination = parse_pagination(params)
metrics = breakdown_metrics(query, [:percentage])
versions =
extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: []
metrics = breakdown_metrics(query, extra_metrics ++ [:percentage])
results =
Stats.breakdown(site, query, metrics, pagination)
|> transform_keys(%{browser_version: :name})
|> transform_keys(%{browser_version: :version})
if params["csv"] do
if Query.get_filter(query, "event:goal") do
versions
|> transform_keys(%{
name: :version,
browser: :name,
visitors: :conversions
})
results
|> transform_keys(%{browser: :name, visitors: :conversions})
|> to_csv([:name, :version, :conversions, :conversion_rate])
else
versions
|> transform_keys(%{name: :version, browser: :name})
results
|> transform_keys(%{browser: :name})
|> to_csv([:name, :version, :visitors])
end
else
results =
if params["detailed"] do
transform_keys(results, %{version: :name})
else
Enum.map(results, &put_combined_name_with_version(&1, :browser))
end
json(conn, %{
results: versions,
results: results,
skip_imported_reason: query.skip_imported_reason
})
end
@ -1155,7 +1167,11 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:os")
query = Query.from(site, params)
pagination = parse_pagination(params)
metrics = breakdown_metrics(query, [:percentage])
extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: []
metrics = breakdown_metrics(query, extra_metrics ++ [:percentage])
systems =
Stats.breakdown(site, query, metrics, pagination)
@ -1182,25 +1198,36 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:os_version")
query = Query.from(site, params)
pagination = parse_pagination(params)
metrics = breakdown_metrics(query, [:percentage])
versions =
extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: []
metrics = breakdown_metrics(query, extra_metrics ++ [:percentage])
results =
Stats.breakdown(site, query, metrics, pagination)
|> transform_keys(%{os_version: :name})
|> transform_keys(%{os_version: :version})
if params["csv"] do
if Query.get_filter(query, "event:goal") do
versions
|> transform_keys(%{name: :version, os: :name, visitors: :conversions})
results
|> transform_keys(%{os: :name, visitors: :conversions})
|> to_csv([:name, :version, :conversions, :conversion_rate])
else
versions
|> transform_keys(%{name: :version, os: :name})
results
|> transform_keys(%{os: :name})
|> to_csv([:name, :version, :visitors])
end
else
results =
if params["detailed"] do
transform_keys(results, %{version: :name})
else
Enum.map(results, &put_combined_name_with_version(&1, :os))
end
json(conn, %{
results: versions,
results: results,
skip_imported_reason: query.skip_imported_reason
})
end
@ -1211,7 +1238,11 @@ defmodule PlausibleWeb.Api.StatsController do
params = Map.put(params, "property", "visit:device")
query = Query.from(site, params)
pagination = parse_pagination(params)
metrics = breakdown_metrics(query, [:percentage])
extra_metrics =
if params["detailed"], do: [:bounce_rate, :visit_duration], else: []
metrics = breakdown_metrics(query, extra_metrics ++ [:percentage])
sizes =
Stats.breakdown(site, query, metrics, pagination)
@ -1539,4 +1570,14 @@ defmodule PlausibleWeb.Api.StatsController do
[:visitors] ++ extra_metrics
end
end
def put_combined_name_with_version(row, name_key) do
name =
case {row[name_key], row.version} do
{@not_set, @not_set} -> @not_set
{browser_or_os, version} -> "#{browser_or_os} #{version}"
end
Map.put(row, :name, name)
end
end

View File

@ -225,17 +225,19 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
|> Map.get("results")
assert %{
"name" => "Chrome 110",
"browser" => "Chrome",
"conversion_rate" => 66.7,
"name" => "110",
"version" => "110",
"total_visitors" => 3,
"visitors" => 2
} == List.first(json_response)
assert %{
"name" => "Firefox 121",
"browser" => "Firefox",
"conversion_rate" => 100.0,
"name" => "121",
"version" => "121",
"total_visitors" => 1,
"visitors" => 1
} == List.last(json_response)
@ -258,8 +260,44 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
)
assert json_response(conn, 200)["results"] == [
%{"name" => "78.0", "visitors" => 2, "percentage" => 66.7, "browser" => "Chrome"},
%{"name" => "77.0", "visitors" => 1, "percentage" => 33.3, "browser" => "Chrome"}
%{
"name" => "Chrome 78.0",
"version" => "78.0",
"visitors" => 2,
"percentage" => 66.7,
"browser" => "Chrome"
},
%{
"name" => "Chrome 77.0",
"version" => "77.0",
"visitors" => 1,
"percentage" => 33.3,
"browser" => "Chrome"
}
]
end
test "returns only version under the name key (+ additional metrics) when 'detailed' is true in params",
%{
conn: conn,
site: site
} do
populate_stats(site, [build(:pageview, browser: "Chrome", browser_version: "78.0")])
filters = Jason.encode!(%{browser: "Chrome"})
conn =
get(conn, "/api/stats/#{site.domain}/browser-versions?filters=#{filters}&detailed=true")
assert json_response(conn, 200)["results"] == [
%{
"name" => "78.0",
"browser" => "Chrome",
"visitors" => 1,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 100.0
}
]
end
@ -281,7 +319,8 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
"name" => "(not set)",
"visitors" => 1,
"percentage" => 100,
"browser" => "(not set)"
"browser" => "(not set)",
"version" => "(not set)"
}
]
end
@ -323,13 +362,32 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
assert json_response(conn, 200)["results"] == [
%{
"browser" => "(not set)",
"version" => "(not set)",
"name" => "(not set)",
"visitors" => 10,
"percentage" => 50.0
},
%{"browser" => "Chrome", "name" => "121", "visitors" => 6, "percentage" => 30.0},
%{"browser" => "Firefox", "name" => "121", "visitors" => 3, "percentage" => 15.0},
%{"browser" => "Chrome", "name" => "110", "visitors" => 1, "percentage" => 5.0}
%{
"browser" => "Chrome",
"version" => "121",
"name" => "Chrome 121",
"visitors" => 6,
"percentage" => 30.0
},
%{
"browser" => "Firefox",
"version" => "121",
"name" => "Firefox 121",
"visitors" => 3,
"percentage" => 15.0
},
%{
"browser" => "Chrome",
"version" => "110",
"name" => "Chrome 110",
"visitors" => 1,
"percentage" => 5.0
}
]
end
end

View File

@ -246,8 +246,49 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
)
assert json_response(conn, 200)["results"] == [
%{"name" => "10.16", "visitors" => 2, "percentage" => 66.7, "os" => "Mac"},
%{"name" => "10.15", "visitors" => 1, "percentage" => 33.3, "os" => "Mac"}
%{
"name" => "Mac 10.16",
"visitors" => 2,
"percentage" => 66.7,
"os" => "Mac",
"version" => "10.16"
},
%{
"name" => "Mac 10.15",
"visitors" => 1,
"percentage" => 33.3,
"os" => "Mac",
"version" => "10.15"
}
]
end
test "returns only version under the name key (+ additional metrics) when 'detailed' is true in params",
%{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview, operating_system: "Mac", operating_system_version: "14")
])
filters = Jason.encode!(%{os: "Mac"})
conn =
get(
conn,
"/api/stats/#{site.domain}/operating-system-versions?filters=#{filters}&detailed=true"
)
assert json_response(conn, 200)["results"] == [
%{
"name" => "14",
"os" => "Mac",
"visitors" => 1,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 100.0
}
]
end
@ -289,12 +330,31 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
%{
"os" => "(not set)",
"name" => "(not set)",
"version" => "(not set)",
"visitors" => 10,
"percentage" => 50.0
},
%{"os" => "Mac", "name" => "11", "visitors" => 6, "percentage" => 30.0},
%{"os" => "Windows", "name" => "11", "visitors" => 3, "percentage" => 15.0},
%{"os" => "Mac", "name" => "12", "visitors" => 1, "percentage" => 5.0}
%{
"os" => "Mac",
"name" => "Mac 11",
"version" => "11",
"visitors" => 6,
"percentage" => 30.0
},
%{
"os" => "Windows",
"name" => "Windows 11",
"version" => "11",
"visitors" => 3,
"percentage" => 15.0
},
%{
"os" => "Mac",
"name" => "Mac 12",
"version" => "12",
"visitors" => 1,
"percentage" => 5.0
}
]
end
end

View File

@ -19,6 +19,38 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do
]
end
test "returns bounce_rate and visit_duration when detailed=true", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 12:00:00], screen_size: "Desktop"),
build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 12:10:00], screen_size: "Desktop"),
build(:pageview, timestamp: ~N[2021-01-01 12:00:00], screen_size: "Desktop"),
build(:pageview, timestamp: ~N[2021-01-01 12:00:00], screen_size: "Laptop")
])
conn =
get(
conn,
"/api/stats/#{site.domain}/screen-sizes?period=day&date=2021-01-01&detailed=true"
)
assert json_response(conn, 200)["results"] == [
%{
"name" => "Desktop",
"visitors" => 2,
"bounce_rate" => 50,
"visit_duration" => 300,
"percentage" => 66.7
},
%{
"name" => "Laptop",
"visitors" => 1,
"bounce_rate" => 100,
"visit_duration" => 0,
"percentage" => 33.3
}
]
end
test "returns screen sizes for user making multiple sessions", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview,