From 668afa07e6d5fdef6bae8552709cae56b343f166 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Fri, 3 Sep 2021 23:12:23 +1200 Subject: [PATCH 1/7] Add conversion_rate to sources api and source table --- assets/js/dashboard/number-formatter.js | 4 ++ .../js/dashboard/stats/sources/source-list.js | 41 +++++++++++++++---- .../controllers/api/stats_controller.ex | 32 +++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/assets/js/dashboard/number-formatter.js b/assets/js/dashboard/number-formatter.js index c724525ef8a8..db76a0950394 100644 --- a/assets/js/dashboard/number-formatter.js +++ b/assets/js/dashboard/number-formatter.js @@ -49,3 +49,7 @@ export function durationFormatter(duration) { return `${seconds}s` } } + +export function percentageFormatter(num) { + return +(Math.round(num + "e+2") + "e-2"); +} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index db0d99c923da..b8c784922d5c 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -6,7 +6,7 @@ import * as storage from '../../storage' import FadeIn from '../../fade-in' import Bar from '../bar' import MoreLink from '../more-link' -import numberFormatter from '../../number-formatter' +import numberFormatter, {percentageFormatter} from '../../number-formatter' import * as api from '../../api' import LazyLoader from '../../lazy-loader' @@ -33,15 +33,22 @@ class AllSources extends React.Component { return this.props.query.period === 'realtime' } + hasGoalFilter() { + return !!this.props.query.filters.goal + } + fetchReferrers() { api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/sources`, this.props.query, {show_noref: this.showNoRef()}) - .then((res) => this.setState({loading: false, referrers: res})) + .then((res) => this.setState({loading: false, referrers: res})) } renderReferrer(referrer) { const query = new URLSearchParams(window.location.search) query.set('source', referrer.name) + const showCR = this.hasGoalFilter() + const maxWidthDeduction = showCR ? "10rem" : "5rem" + return (
- {numberFormatter(referrer.count)} + {numberFormatter(referrer.count)} + {showCR && {percentageFormatter(referrer.conversion_rate)}%}
) } @@ -76,12 +84,17 @@ class AllSources extends React.Component { } renderList() { + const showCR = this.hasGoalFilter() + if (this.state.referrers && this.state.referrers.length > 0) { return (
Source - {this.label()} +
+ {this.label()} + {showCR && CR} +
@@ -149,6 +162,10 @@ class UTMSources extends React.Component { return this.props.query.period === 'realtime' } + hasGoalFilter() { + return !!this.props.query.filters.goal + } + fetchReferrers() { const endpoint = UTM_TAGS[this.props.tab].endpoint api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/${endpoint}`, this.props.query, {show_noref: this.showNoRef()}) @@ -158,6 +175,8 @@ class UTMSources extends React.Component { renderReferrer(referrer) { const query = new URLSearchParams(window.location.search) query.set(this.props.tab, referrer.name) + const showCR = this.hasGoalFilter() + const maxWidthDeduction = showCR ? "10rem" : "5rem" return (
@@ -180,7 +199,8 @@ class UTMSources extends React.Component { - {numberFormatter(referrer.count)} + {numberFormatter(referrer.count)} + {showCR && {percentageFormatter(referrer.conversion_rate)}%}
) } @@ -190,12 +210,17 @@ class UTMSources extends React.Component { } renderList() { + const showCR = this.hasGoalFilter() + if (this.state.referrers && this.state.referrers.length > 0) { return (
{UTM_TAGS[this.props.tab].label} - {this.label()} +
+ {this.label()} + {showCR && CR} +
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index f85078041ad1..f3aa20a539ea 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -213,6 +213,7 @@ defmodule PlausibleWeb.Api.StatsController do res = Stats.breakdown(site, query, "visit:source", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "source", "visit:source") |> transform_keys(%{"source" => "name", "visitors" => "count"}) json(conn, res) @@ -231,6 +232,7 @@ defmodule PlausibleWeb.Api.StatsController do res = Stats.breakdown(site, query, "visit:utm_medium", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "utm_medium", "visit:utm_medium") |> transform_keys(%{"utm_medium" => "name", "visitors" => "count"}) json(conn, res) @@ -249,6 +251,7 @@ defmodule PlausibleWeb.Api.StatsController do res = Stats.breakdown(site, query, "visit:utm_campaign", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "utm_campaign", "visit:utm_campaign") |> transform_keys(%{"utm_campaign" => "name", "visitors" => "count"}) json(conn, res) @@ -267,6 +270,7 @@ defmodule PlausibleWeb.Api.StatsController do res = Stats.breakdown(site, query, "visit:utm_source", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "utm_source", "visit:utm_source") |> transform_keys(%{"utm_source" => "name", "visitors" => "count"}) json(conn, res) @@ -580,4 +584,32 @@ defmodule PlausibleWeb.Api.StatsController do query end end + + defp add_cr(list, list_without_goals, key_name) do + Enum.map(list, fn item -> + without_goal = Enum.find(list_without_goals, fn s -> s[key_name] === item[key_name] end) + + item + |> Map.put(:conversion_rate, calculate_cr(without_goal["visitors"], item["visitors"])) + end) + end + + defp maybe_add_cr(list, site, query, pagination, key_name, filter_name) do + if Map.has_key?(query.filters, "event:goal") do + items = Enum.map(list, fn item -> item[key_name] end) + + query_without_goal = + query + |> Query.put_filter(filter_name, {:member, items}) + |> Query.remove_goal() + + res_without_goal = + Stats.breakdown(site, query_without_goal, filter_name, ["visitors"], pagination) + + list + |> add_cr(res_without_goal, key_name) + else + list + end + end end From fa4d581461ecf249bf4c40ff58b3fd6d9b413ca2 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 12 Sep 2021 15:36:04 +1200 Subject: [PATCH 2/7] Remove percentageFormatter --- assets/js/dashboard/number-formatter.js | 4 ---- assets/js/dashboard/stats/sources/source-list.js | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/number-formatter.js b/assets/js/dashboard/number-formatter.js index db76a0950394..c724525ef8a8 100644 --- a/assets/js/dashboard/number-formatter.js +++ b/assets/js/dashboard/number-formatter.js @@ -49,7 +49,3 @@ export function durationFormatter(duration) { return `${seconds}s` } } - -export function percentageFormatter(num) { - return +(Math.round(num + "e+2") + "e-2"); -} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index b8c784922d5c..39be617c0a43 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -6,7 +6,7 @@ import * as storage from '../../storage' import FadeIn from '../../fade-in' import Bar from '../bar' import MoreLink from '../more-link' -import numberFormatter, {percentageFormatter} from '../../number-formatter' +import numberFormatter from '../../number-formatter' import * as api from '../../api' import LazyLoader from '../../lazy-loader' @@ -74,7 +74,7 @@ class AllSources extends React.Component { {numberFormatter(referrer.count)} - {showCR && {percentageFormatter(referrer.conversion_rate)}%} + {showCR && {referrer.conversion_rate}%}
) } @@ -200,7 +200,7 @@ class UTMSources extends React.Component { {numberFormatter(referrer.count)} - {showCR && {percentageFormatter(referrer.conversion_rate)}%} + {showCR && {referrer.conversion_rate}%} ) } From 271e55699a669ed66a13dfc05e79cf872ffbac5f Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 12 Sep 2021 15:46:00 +1200 Subject: [PATCH 3/7] Update source tests to include conversionat rate --- .../controllers/api/stats_controller/sources_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/plausible_web/controllers/api/stats_controller/sources_test.exs b/test/plausible_web/controllers/api/stats_controller/sources_test.exs index a7a6e5ceda07..b9181dd5bad2 100644 --- a/test/plausible_web/controllers/api/stats_controller/sources_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/sources_test.exs @@ -283,7 +283,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do describe "GET /api/stats/:domain/sources - with goal filter" do setup [:create_user, :log_in, :create_new_site] - test "returns top referrers for a custom goal", %{conn: conn, site: site} do + test "returns top referrers for a custom goal including conversion_rate", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, referrer_source: "Twitter", @@ -307,11 +307,11 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do ) assert json_response(conn, 200) == [ - %{"name" => "Twitter", "count" => 1} + %{"name" => "Twitter", "count" => 1, "conversion_rate" => 50.0} ] end - test "returns top referrers for a pageview goal", %{conn: conn, site: site} do + test "returns top referrers for a pageview goal including conversion_rate", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, referrer_source: "Twitter", @@ -335,7 +335,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do ) assert json_response(conn, 200) == [ - %{"name" => "Twitter", "count" => 1} + %{"name" => "Twitter", "count" => 1, "conversion_rate" => 50.0} ] end end From 0ef198ae8c61e4c6fe7012f735c26dba014caae9 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 12 Sep 2021 15:57:02 +1200 Subject: [PATCH 4/7] Add CR to detals modal --- assets/js/dashboard/stats/modals/sources.js | 8 +++++++ .../js/dashboard/stats/sources/source-list.js | 22 +++++++------------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index c5d7ea6cf03b..308e349a5d9f 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -54,6 +54,10 @@ class SourcesModal extends React.Component { return this.state.query.period !== 'realtime' && !this.state.query.filters.goal } + showConversionRate() { + return !!this.state.query.filters.goal + } + loadMore() { this.setState({loading: true, page: this.state.page + 1}, this.loadSources.bind(this)) } @@ -82,6 +86,8 @@ class SourcesModal extends React.Component { if (filter === 'utm_sources') query.set('utm_source', source.name) if (filter === 'utm_campaigns') query.set('utm_campaign', source.name) + console.log(source) + return ( @@ -94,6 +100,7 @@ class SourcesModal extends React.Component { {numberFormatter(source.count)} {this.showExtra() && {this.formatBounceRate(source)} } {this.showExtra() && {this.formatDuration(source)} } + {this.showConversionRate() && {source.conversion_rate}% } ) } @@ -135,6 +142,7 @@ class SourcesModal extends React.Component { {this.label()} {this.showExtra() && Bounce rate} {this.showExtra() && Visit duration} + {this.showConversionRate() && CR} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index 39be617c0a43..e7b8c544ea72 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -33,7 +33,7 @@ class AllSources extends React.Component { return this.props.query.period === 'realtime' } - hasGoalFilter() { + showConversionRate() { return !!this.props.query.filters.goal } @@ -46,8 +46,7 @@ class AllSources extends React.Component { const query = new URLSearchParams(window.location.search) query.set('source', referrer.name) - const showCR = this.hasGoalFilter() - const maxWidthDeduction = showCR ? "10rem" : "5rem" + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
{numberFormatter(referrer.count)} - {showCR && {referrer.conversion_rate}%} + {this.showConversionRate() && {referrer.conversion_rate}%}
) } @@ -84,8 +83,6 @@ class AllSources extends React.Component { } renderList() { - const showCR = this.hasGoalFilter() - if (this.state.referrers && this.state.referrers.length > 0) { return ( @@ -93,7 +90,7 @@ class AllSources extends React.Component { Source
{this.label()} - {showCR && CR} + {this.showConversionRate() && CR}
@@ -162,7 +159,7 @@ class UTMSources extends React.Component { return this.props.query.period === 'realtime' } - hasGoalFilter() { + showConversionRate() { return !!this.props.query.filters.goal } @@ -175,8 +172,7 @@ class UTMSources extends React.Component { renderReferrer(referrer) { const query = new URLSearchParams(window.location.search) query.set(this.props.tab, referrer.name) - const showCR = this.hasGoalFilter() - const maxWidthDeduction = showCR ? "10rem" : "5rem" + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
{numberFormatter(referrer.count)} - {showCR && {referrer.conversion_rate}%} + {this.showConversionRate() && {referrer.conversion_rate}%}
) } @@ -210,8 +206,6 @@ class UTMSources extends React.Component { } renderList() { - const showCR = this.hasGoalFilter() - if (this.state.referrers && this.state.referrers.length > 0) { return (
@@ -219,7 +213,7 @@ class UTMSources extends React.Component { {UTM_TAGS[this.props.tab].label}
{this.label()} - {showCR && CR} + {this.showConversionRate() && CR}
From a1731fcfb95c8eaad949ffb6d18d0b1a1651fc71 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 12 Sep 2021 16:00:40 +1200 Subject: [PATCH 5/7] Correct formatting with linter --- .../controllers/api/stats_controller/sources_test.exs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/plausible_web/controllers/api/stats_controller/sources_test.exs b/test/plausible_web/controllers/api/stats_controller/sources_test.exs index b9181dd5bad2..0494208727de 100644 --- a/test/plausible_web/controllers/api/stats_controller/sources_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/sources_test.exs @@ -283,7 +283,10 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do describe "GET /api/stats/:domain/sources - with goal filter" do setup [:create_user, :log_in, :create_new_site] - test "returns top referrers for a custom goal including conversion_rate", %{conn: conn, site: site} do + test "returns top referrers for a custom goal including conversion_rate", %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:pageview, referrer_source: "Twitter", @@ -311,7 +314,10 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do ] end - test "returns top referrers for a pageview goal including conversion_rate", %{conn: conn, site: site} do + test "returns top referrers for a pageview goal including conversion_rate", %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:pageview, referrer_source: "Twitter", From dcfb1425c7acda7b456ef7816c82fc48580bbab2 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 12 Sep 2021 16:10:35 +1200 Subject: [PATCH 6/7] Add change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b42d03c34b..8b3d32c0bb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. - Added `CLICKHOUSE_FLUSH_INTERVAL_MS` and `CLICKHOUSE_MAX_BUFFER_SIZE` configuration parameters plausible/analytics#1073 - Ability to invite users to sites with different roles plausible/analytics#1122 - Option to configure a custom name for the script file +- Add Conversion Rate to Top Sources when filtered by a goal plausible/analytics#1299 ### Fixed - Fix weekly report time range plausible/analytics#951 From 70e64c0bbbc2f95223ed08836f1cede36438c379 Mon Sep 17 00:00:00 2001 From: Rowan Savage Date: Sun, 19 Sep 2021 23:16:09 +1200 Subject: [PATCH 7/7] Add CR to Pages, Device and Countries panels --- CHANGELOG.md | 2 +- assets/js/dashboard/stats/devices/browsers.js | 20 ++-- assets/js/dashboard/stats/devices/index.js | 28 +++--- .../stats/devices/operating-systems.js | 15 ++- assets/js/dashboard/stats/modals/countries.js | 8 +- .../js/dashboard/stats/modals/entry-pages.js | 10 ++ .../js/dashboard/stats/modals/exit-pages.js | 6 ++ assets/js/dashboard/stats/modals/pages.js | 6 ++ .../js/dashboard/stats/pages/entry-pages.js | 17 +++- assets/js/dashboard/stats/pages/exit-pages.js | 15 ++- assets/js/dashboard/stats/pages/pages.js | 15 ++- .../controllers/api/stats_controller.ex | 9 ++ .../api/stats_controller/browsers_test.exs | 16 +++ .../api/stats_controller/countries_test.exs | 38 +++++++ .../operating_systems_test.exs | 17 ++++ .../api/stats_controller/pages_test.exs | 98 ++++++++++++++++++- .../stats_controller/screen_sizes_test.exs | 21 ++++ 17 files changed, 306 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3d32c0bb78..236f1d89830a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project will be documented in this file. - Added `CLICKHOUSE_FLUSH_INTERVAL_MS` and `CLICKHOUSE_MAX_BUFFER_SIZE` configuration parameters plausible/analytics#1073 - Ability to invite users to sites with different roles plausible/analytics#1122 - Option to configure a custom name for the script file -- Add Conversion Rate to Top Sources when filtered by a goal plausible/analytics#1299 +- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299 ### Fixed - Fix weekly report time range plausible/analytics#951 diff --git a/assets/js/dashboard/stats/devices/browsers.js b/assets/js/dashboard/stats/devices/browsers.js index 7cf964469a76..9411e20619c8 100644 --- a/assets/js/dashboard/stats/devices/browsers.js +++ b/assets/js/dashboard/stats/devices/browsers.js @@ -21,7 +21,7 @@ export default class Browsers extends React.Component { this.fetchBrowsers() } } - + onVisible() { this.fetchBrowsers() if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this)) @@ -37,6 +37,10 @@ export default class Browsers extends React.Component { } } + showConversionRate() { + return !!this.props.query.filters.goal + } + label() { return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' } @@ -58,6 +62,7 @@ export default class Browsers extends React.Component { } else { query.set('browser', browser.name) } + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
@@ -65,14 +70,14 @@ export default class Browsers extends React.Component { count={browser.count} all={this.state.browsers} bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15" - maxWidthDeduction="6rem" + maxWidthDeduction={maxWidthDeduction} > {this.renderBrowserContent(browser, query)} - - {numberFormatter(browser.count)} - ({browser.percentage}%) + + {numberFormatter(browser.count)} ({browser.percentage}%) + {this.showConversionRate() && {numberFormatter(browser.conversion_rate)}%}
) } @@ -85,7 +90,10 @@ export default class Browsers extends React.Component {
{ key } - { this.label() } +
+ { this.label() } + {this.showConversionRate() && CR} +
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index f8f454c5230c..1bcdd21e6790 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -57,7 +57,6 @@ class ScreenSizes extends React.Component { if (this.props.timer) this.props.timer.onTick(this.fetchScreenSizes.bind(this)) } - fetchScreenSizes() { api.get( `/api/stats/${encodeURIComponent(this.props.site.domain)}/screen-sizes`, @@ -66,6 +65,11 @@ class ScreenSizes extends React.Component { .then((res) => this.setState({loading: false, sizes: res})) } + showConversionRate() { + return !!this.props.query.filters.goal + } + + label() { return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' } @@ -73,6 +77,7 @@ class ScreenSizes extends React.Component { renderScreenSize(size) { const query = new URLSearchParams(window.location.search) query.set('screen', size.name) + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
@@ -80,7 +85,7 @@ class ScreenSizes extends React.Component { count={size.count} all={this.state.sizes} bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15" - maxWidthDeduction="6rem" + maxWidthDeduction={maxWidthDeduction} > - - {numberFormatter(size.count)} - ({size.percentage}%) + + {numberFormatter(size.count)} ({size.percentage}%) + {this.showConversionRate() && {numberFormatter(size.conversion_rate)}%}
) } @@ -109,7 +112,10 @@ class ScreenSizes extends React.Component { className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500" > Screen size - { this.label() } +
+ { this.label() } + {this.showConversionRate() && CR} +
{ this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) }
@@ -146,14 +152,14 @@ export default class Devices extends React.Component { } } - + setMode(mode) { return () => { storage.setItem(this.tabKey, mode) this.setState({mode}) } } - + renderContent() { switch (this.state.mode) { case 'browser': @@ -190,7 +196,7 @@ export default class Devices extends React.Component { ) } - + return (
  • @@ -61,7 +66,8 @@ export default class OperatingSystems extends React.Component { - {numberFormatter(os.count)} ({os.percentage}%) + {numberFormatter(os.count)} ({os.percentage}%) + {this.showConversionRate() && {numberFormatter(os.conversion_rate)}%} ) } @@ -78,7 +84,10 @@ export default class OperatingSystems extends React.Component {
    { key } - { this.label() } +
    + { this.label() } + {this.showConversionRate() && CR} +
    { this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
    diff --git a/assets/js/dashboard/stats/modals/countries.js b/assets/js/dashboard/stats/modals/countries.js index 0442aaa70999..ff5cb9e8ace2 100644 --- a/assets/js/dashboard/stats/modals/countries.js +++ b/assets/js/dashboard/stats/modals/countries.js @@ -25,6 +25,10 @@ class CountriesModal extends React.Component { return this.state.query.period === 'realtime' ? 'Current visitors' : 'Visitors' } + showConversionRate() { + return !!this.state.query.filters.goal + } + renderCountry(country) { const query = new URLSearchParams(window.location.search) query.set('country', country.name) @@ -47,6 +51,7 @@ class CountriesModal extends React.Component { {numberFormatter(country.count)} ({country.percentage}%) + {this.showConversionRate() && {country.conversion_rate}% } ) } @@ -57,7 +62,7 @@ class CountriesModal extends React.Component {
    ) } - + if (this.state.countries) { return ( <> @@ -81,6 +86,7 @@ class CountriesModal extends React.Component { > {this.label()} + {this.showConversionRate() && CR} diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 386a909110f0..e8332726de11 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -56,6 +56,10 @@ class EntryPagesModal extends React.Component { return '-'; } + showConversionRate() { + return !!this.state.query.filters.goal + } + renderPage(page) { const query = new URLSearchParams(window.location.search) query.set('entry_page', page.name) @@ -76,6 +80,7 @@ class EntryPagesModal extends React.Component { {numberFormatter(page.count)} {numberFormatter(page.entries)} {this.showVisitDuration() && {durationFormatter(page.visit_duration)}} + {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%} ) } @@ -125,6 +130,11 @@ class EntryPagesModal extends React.Component { align="right" >Visit Duration + {this.showConversionRate() && CR + } diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 384ee420dd62..f88c24008cdc 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -42,6 +42,10 @@ class ExitPagesModal extends React.Component { } } + showConversionRate() { + return !!this.state.query.filters.goal + } + renderPage(page) { const query = new URLSearchParams(window.location.search) query.set('exit_page', page.name) @@ -54,6 +58,7 @@ class ExitPagesModal extends React.Component { {numberFormatter(page.count)} {numberFormatter(page.exits)} {this.formatPercentage(page.exit_rate)} + {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%} ) } @@ -87,6 +92,7 @@ class ExitPagesModal extends React.Component { Unique Exits Total Exits Exit Rate + {this.showConversionRate() && CR} diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 3b7753a52313..df61b5998fb6 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -44,6 +44,10 @@ class PagesModal extends React.Component { return this.state.query.period !== 'realtime' && !(filters.goal || filters.source || filters.referrer) } + showConversionRate() { + return !!this.state.query.filters.goal + } + formatBounceRate(page) { if (typeof(page.bounce_rate) === 'number') { return page.bounce_rate + '%' @@ -66,6 +70,7 @@ class PagesModal extends React.Component { {this.showPageviews() && {numberFormatter(page.pageviews)} } {this.showExtra() && {this.formatBounceRate(page)} } {this.showExtra() && {timeOnPage} } + {this.showConversionRate() && {page.conversion_rate}% } ) } @@ -104,6 +109,7 @@ class PagesModal extends React.Component { {this.showPageviews() && Pageviews} {this.showExtra() && Bounce rate} {this.showExtra() && Time on Page} + {this.showConversionRate() && CR} diff --git a/assets/js/dashboard/stats/pages/entry-pages.js b/assets/js/dashboard/stats/pages/entry-pages.js index c9787e043cf1..9ca28bc39e56 100644 --- a/assets/js/dashboard/stats/pages/entry-pages.js +++ b/assets/js/dashboard/stats/pages/entry-pages.js @@ -29,6 +29,10 @@ export default class EntryPages extends React.Component { } } + showConversionRate() { + return !!this.props.query.filters.goal + } + fetchPages() { api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`, this.props.query) .then((res) => this.setState({loading: false, pages: res})) @@ -37,6 +41,7 @@ export default class EntryPages extends React.Component { renderPage(page) { const query = new URLSearchParams(window.location.search) query.set('entry_page', page.name) + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
    @@ -44,7 +49,7 @@ export default class EntryPages extends React.Component { count={page.count} all={this.state.pages} bg="bg-orange-50 dark:bg-gray-500 dark:bg-opacity-15" - maxWidthDeduction="4rem" + maxWidthDeduction={maxWidthDeduction} > - {numberFormatter(page.count)} + {numberFormatter(page.count)} + {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%}
    ) } @@ -73,7 +79,10 @@ export default class EntryPages extends React.Component { <>
    Page url - Unique Entrances +
    + Unique Entrances + {this.showConversionRate() && CR} +
    @@ -81,7 +90,7 @@ export default class EntryPages extends React.Component { ) - } + } return (
    this.setState({loading: false, pages: res})) @@ -37,6 +41,7 @@ export default class ExitPages extends React.Component { renderPage(page) { const query = new URLSearchParams(window.location.search) query.set('exit_page', page.name) + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
    @@ -44,7 +49,7 @@ export default class ExitPages extends React.Component { count={page.count} all={this.state.pages} bg="bg-orange-50 dark:bg-gray-500 dark:bg-opacity-15" - maxWidthDeduction="4rem" + maxWidthDeduction={maxWidthDeduction} > - {numberFormatter(page.count)} + {numberFormatter(page.count)} + {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%}
    ) } @@ -73,7 +79,10 @@ export default class ExitPages extends React.Component {
    Page url - Unique Exits +
    + Unique Exits + {this.showConversionRate() && CR} +
    diff --git a/assets/js/dashboard/stats/pages/pages.js b/assets/js/dashboard/stats/pages/pages.js index 92dd87cf7353..d8e350ba7d43 100644 --- a/assets/js/dashboard/stats/pages/pages.js +++ b/assets/js/dashboard/stats/pages/pages.js @@ -29,6 +29,10 @@ export default class Visits extends React.Component { } } + showConversionRate() { + return !!this.props.query.filters.goal + } + fetchPages() { api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, this.props.query) .then((res) => this.setState({loading: false, pages: res})) @@ -39,6 +43,7 @@ export default class Visits extends React.Component { query.set('page', page.name) const domain = new URL('https://' + this.props.site.domain) const externalLink = 'https://' + domain.host + page.name + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" return (
    - {numberFormatter(page.count)} + {numberFormatter(page.count)} + {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%}
    ) } @@ -89,7 +95,10 @@ export default class Visits extends React.Component {
    Page url - { this.label() } +
    + { this.label() } + {this.showConversionRate() && CR} +
    diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index f3aa20a539ea..e8e3a50d52d4 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -336,6 +336,7 @@ defmodule PlausibleWeb.Api.StatsController do pages = Stats.breakdown(site, query, "event:page", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "page", "event:page") |> transform_keys(%{"page" => "name", "visitors" => "count"}) json(conn, pages) @@ -349,6 +350,7 @@ defmodule PlausibleWeb.Api.StatsController do entry_pages = Stats.breakdown(site, query, "visit:entry_page", metrics, pagination) + |> maybe_add_cr(site, query, pagination, "entry_page", "visit:entry_page") |> transform_keys(%{"entry_page" => "name", "visits" => "entries", "visitors" => "count"}) json(conn, entry_pages) @@ -362,6 +364,7 @@ defmodule PlausibleWeb.Api.StatsController do exit_pages = Stats.breakdown(site, query, "visit:exit_page", metrics, {limit, page}) + |> maybe_add_cr(site, query, {limit, page}, "exit_page", "visit:exit_page") |> transform_keys(%{"exit_page" => "name", "visits" => "exits", "visitors" => "count"}) pages = Enum.map(exit_pages, & &1["name"]) @@ -399,6 +402,7 @@ defmodule PlausibleWeb.Api.StatsController do countries = Stats.breakdown(site, query, "visit:country", ["visitors"], {300, 1}) + |> maybe_add_cr(site, query, {300, 1}, "country", "visit:country") |> transform_keys(%{"country" => "name", "visitors" => "count"}) |> Enum.map(fn country -> alpha3 = Stats.CountryName.to_alpha3(country["name"]) @@ -416,6 +420,7 @@ defmodule PlausibleWeb.Api.StatsController do browsers = Stats.breakdown(site, query, "visit:browser", ["visitors"], pagination) + |> maybe_add_cr(site, query, pagination, "browser", "visit:browser") |> transform_keys(%{"browser" => "name", "visitors" => "count"}) |> add_percentages @@ -429,6 +434,7 @@ defmodule PlausibleWeb.Api.StatsController do versions = Stats.breakdown(site, query, "visit:browser_version", ["visitors"], pagination) + |> maybe_add_cr(site, query, pagination, "browser_version", "visit:browser_version") |> transform_keys(%{"browser_version" => "name", "visitors" => "count"}) |> add_percentages @@ -442,6 +448,7 @@ defmodule PlausibleWeb.Api.StatsController do systems = Stats.breakdown(site, query, "visit:os", ["visitors"], pagination) + |> maybe_add_cr(site, query, pagination, "os", "visit:os") |> transform_keys(%{"os" => "name", "visitors" => "count"}) |> add_percentages @@ -455,6 +462,7 @@ defmodule PlausibleWeb.Api.StatsController do versions = Stats.breakdown(site, query, "visit:os_version", ["visitors"], pagination) + |> maybe_add_cr(site, query, pagination, "os_version", "visit:os_version") |> transform_keys(%{"os_version" => "name", "visitors" => "count"}) |> add_percentages @@ -468,6 +476,7 @@ defmodule PlausibleWeb.Api.StatsController do sizes = Stats.breakdown(site, query, "visit:device", ["visitors"], pagination) + |> maybe_add_cr(site, query, pagination, "device", "visit:device") |> transform_keys(%{"device" => "name", "visitors" => "count"}) |> add_percentages diff --git a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs index 7f540ade4eaf..31d4b1569fa9 100644 --- a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs @@ -19,6 +19,22 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do %{"name" => "Firefox", "count" => 1, "percentage" => 33} ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, browser: "Chrome"), + build(:pageview, user_id: 2, browser: "Chrome"), + build(:event, user_id: 1, name: "Signup") + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/browsers?period=day&filters=#{filters}") + + assert json_response(conn, 200) == [ + %{"name" => "Chrome", "count" => 1, "percentage" => 100, "conversion_rate" => 50.0} + ] + end end describe "GET /api/stats/:domain/browser-versions" do diff --git a/test/plausible_web/controllers/api/stats_controller/countries_test.exs b/test/plausible_web/controllers/api/stats_controller/countries_test.exs index c7dc27cca4b8..361621f067b6 100644 --- a/test/plausible_web/controllers/api/stats_controller/countries_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/countries_test.exs @@ -33,5 +33,43 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do } ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE" + ), + build(:event, user_id: 1, name: "Signup"), + build(:pageview, + user_id: 2, + country_code: "EE" + ), + build(:pageview, + user_id: 3, + country_code: "GB" + ), + build(:event, user_id: 3, name: "Signup") + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/countries?period=day&filters=#{filters}") + + assert json_response(conn, 200) == [ + %{ + "name" => "GBR", + "count" => 1, + "percentage" => 50, + "conversion_rate" => 100.0 + }, + %{ + "name" => "EST", + "count" => 1, + "percentage" => 50, + "conversion_rate" => 50.0 + } + ] + end end end diff --git a/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs index 1eb612bb9fb4..676c8802034e 100644 --- a/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs @@ -19,6 +19,23 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do %{"name" => "Android", "count" => 1, "percentage" => 33} ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, operating_system: "Mac"), + build(:pageview, user_id: 2, operating_system: "Mac"), + build(:event, user_id: 1, name: "Signup") + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = + get(conn, "/api/stats/#{site.domain}/operating-systems?period=day&filters=#{filters}") + + assert json_response(conn, 200) == [ + %{"name" => "Mac", "count" => 1, "percentage" => 100, "conversion_rate" => 50.0} + ] + end end describe "GET /api/stats/:domain/operating-system-versions" do diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 5606cfd20166..e930827dc86b 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -81,6 +81,23 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do %{"count" => 1, "name" => "/page2"} ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/"), + build(:pageview, user_id: 2, pathname: "/"), + build(:pageview, user_id: 3, pathname: "/"), + build(:event, user_id: 3, name: "Signup") + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200) == [ + %{"count" => 1, "name" => "/", "conversion_rate" => 33.3} + ] + end end describe "GET /api/stats/:domain/entry-pages" do @@ -133,6 +150,66 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do } ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:event, + name: "Signup", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200) == [ + %{ + "count" => 1, + "entries" => 1, + "name" => "/page2", + "visit_duration" => 900, + "conversion_rate" => 100.0 + }, + %{ + "count" => 1, + "entries" => 1, + "name" => "/page1", + "visit_duration" => 0, + "conversion_rate" => 50.0 + } + ] + end end describe "GET /api/stats/:domain/exit-pages" do @@ -168,7 +245,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ] end - test "calculates correct exit rate when filtering for goal", %{conn: conn, site: site} do + test "calculates correct exit rate and conversion_rate when filtering for goal", %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:event, name: "Signup", @@ -206,8 +286,20 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) assert json_response(conn, 200) == [ - %{"name" => "/exit1", "count" => 1, "exits" => 1, "exit_rate" => 50}, - %{"name" => "/exit2", "count" => 1, "exits" => 1, "exit_rate" => 100} + %{ + "name" => "/exit1", + "count" => 1, + "exits" => 1, + "exit_rate" => 50, + "conversion_rate" => 100.0 + }, + %{ + "name" => "/exit2", + "count" => 1, + "exits" => 1, + "exit_rate" => 100, + "conversion_rate" => 100.0 + } ] end diff --git a/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs b/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs index 0510dfab854b..f446595cee92 100644 --- a/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs @@ -19,5 +19,26 @@ defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do %{"name" => "Laptop", "count" => 1, "percentage" => 33} ] end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, screen_size: "Desktop"), + build(:pageview, user_id: 2, screen_size: "Desktop"), + build(:event, user_id: 1, name: "Signup") + ]) + + filters = Jason.encode!(%{"goal" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/screen-sizes?period=day&filters=#{filters}") + + assert json_response(conn, 200) == [ + %{ + "name" => "Desktop", + "count" => 1, + "percentage" => 100, + "conversion_rate" => 50.0 + } + ] + end end end