Add conversion_rate to sources api and source table (#1299)

* Add conversion_rate to sources api and source table

* Remove percentageFormatter

* Update source tests to include conversionat rate

* Add CR to detals modal

* Correct formatting with linter

* Add change log

* Add CR to Pages, Device and Countries panels
This commit is contained in:
Ro Savage 2021-09-21 02:17:11 +12:00 committed by GitHub
parent d9a6ac4fd1
commit b3bc796d50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 382 additions and 45 deletions

View File

@ -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, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299
### Fixed
- Fix weekly report time range plausible/analytics#951

View File

@ -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 (
<div className="flex items-center justify-between my-1 text-sm" key={browser.name}>
@ -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)}
</Bar>
<span className="font-medium dark:text-gray-200">
{numberFormatter(browser.count)}
<span className="inline-block w-8 text-xs text-right">({browser.percentage}%)</span>
<span className="font-medium dark:text-gray-200 text-right w-20">
{numberFormatter(browser.count)} <span className="inline-block w-8 text-xs"> ({browser.percentage}%)</span>
</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(browser.conversion_rate)}%</span>}
</div>
)
}
@ -85,7 +90,10 @@ export default class Browsers extends React.Component {
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>{ key }</span>
<span>{ this.label() }</span>
<div className="text-right">
<span className="inline-block w-20">{ this.label() }</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
</React.Fragment>

View File

@ -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 (
<div className="flex items-center justify-between my-1 text-sm" key={size.name}>
@ -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}
>
<span
tooltip={EXPLANATION[size.name]}
@ -91,12 +96,10 @@ class ScreenSizes extends React.Component {
</Link>
</span>
</Bar>
<span
className="font-medium dark:text-gray-200"
>
{numberFormatter(size.count)}
<span className="inline-block w-8 text-xs text-right">({size.percentage}%)</span>
<span className="font-medium dark:text-gray-200 text-right w-20">
{numberFormatter(size.count)} <span className="inline-block w-8 text-xs text-right">({size.percentage}%)</span>
</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(size.conversion_rate)}%</span>}
</div>
)
}
@ -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"
>
<span>Screen size</span>
<span>{ this.label() }</span>
<div className="text-right">
<span className="inline-block w-20">{ this.label() }</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
{ this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) }
</React.Fragment>
@ -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 {
</li>
)
}
return (
<li
className="cursor-pointer hover:text-indigo-600"

View File

@ -36,6 +36,10 @@ export default class OperatingSystems extends React.Component {
}
}
showConversionRate() {
return !!this.props.query.filters.goal
}
renderOperatingSystem(os) {
const query = new URLSearchParams(window.location.search)
if (this.props.query.filters.os) {
@ -43,6 +47,7 @@ export default class OperatingSystems extends React.Component {
} else {
query.set('os', os.name)
}
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
return (
<div
@ -53,7 +58,7 @@ export default class OperatingSystems extends React.Component {
count={os.count}
all={this.state.operatingSystems}
bg="bg-green-50 dark:gray-500 dark:bg-opacity-15"
maxWidthDeduction="6rem"
maxWidthDeduction={maxWidthDeduction}
>
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
<Link className="md:truncate block hover:underline" to={{search: query.toString()}}>
@ -61,7 +66,8 @@ export default class OperatingSystems extends React.Component {
</Link>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(os.count)} <span className="inline-block w-8 text-xs text-right">({os.percentage}%)</span></span>
<span className="font-medium dark:text-gray-200 text-right w-20">{numberFormatter(os.count)} <span className="inline-block w-8 text-xs text-right">({os.percentage}%)</span></span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(os.conversion_rate)}%</span>}
</div>
)
}
@ -78,7 +84,10 @@ export default class OperatingSystems extends React.Component {
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>{ key }</span>
<span>{ this.label() }</span>
<div className="text-right">
<span className="inline-block w-20">{ this.label() }</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
</React.Fragment>

View File

@ -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 {
<td className="p-2 w-32 font-medium" align="right">
{numberFormatter(country.count)} <span className="inline-block text-xs w-8 text-right">({country.percentage}%)</span>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{country.conversion_rate}%</td> }
</tr>
)
}
@ -57,7 +62,7 @@ class CountriesModal extends React.Component {
<div className="loading mt-32 mx-auto"><div></div></div>
)
}
if (this.state.countries) {
return (
<>
@ -81,6 +86,7 @@ class CountriesModal extends React.Component {
>
{this.label()}
</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>

View File

@ -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 {
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.count)}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.entries)}</td>
{this.showVisitDuration() && <td className="p-2 w-32 font-medium" align="right">{durationFormatter(page.visit_duration)}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
</tr>
)
}
@ -125,6 +130,11 @@ class EntryPagesModal extends React.Component {
align="right"
>Visit Duration
</th>
{this.showConversionRate() && <th
className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="right"
>CR
</th>}
</tr>
</thead>
<tbody>

View File

@ -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 {
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.count)}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.exits)}</td>
<td className="p-2 w-32 font-medium" align="right">{this.formatPercentage(page.exit_rate)}</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
</tr>
)
}
@ -87,6 +92,7 @@ class ExitPagesModal extends React.Component {
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Unique Exits</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total Exits</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Exit Rate</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>

View File

@ -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() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.pageviews)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{timeOnPage}</td> }
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{page.conversion_rate}%</td> }
</tr>
)
}
@ -104,6 +109,7 @@ class PagesModal extends React.Component {
{this.showPageviews() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Pageviews</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Time on Page</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>

View File

@ -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 (
<tr className="text-sm dark:text-gray-200" key={source.name}>
<td className="p-2">
@ -94,6 +100,7 @@ class SourcesModal extends React.Component {
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.count)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(source)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(source)}</td> }
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{source.conversion_rate}%</td> }
</tr>
)
}
@ -135,6 +142,7 @@ class SourcesModal extends React.Component {
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visit duration</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>

View File

@ -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 (
<div className="flex items-center justify-between my-1 text-sm" key={page.name}>
@ -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}
>
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative break-all z-9">
<Link
@ -62,7 +67,8 @@ export default class EntryPages extends React.Component {
</a>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(page.count)}</span>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.conversion_rate)}%</span>}
</div>
)
}
@ -73,7 +79,10 @@ export default class EntryPages extends React.Component {
<>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Page url</span>
<span>Unique Entrances</span>
<div className="text-right">
<span className="inline-block w-30">Unique Entrances</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove>
@ -81,7 +90,7 @@ export default class EntryPages extends React.Component {
</FlipMove>
</>
)
}
}
return (
<div
className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400"

View File

@ -29,6 +29,10 @@ export default class ExitPages extends React.Component {
}
}
showConversionRate() {
return !!this.props.query.filters.goal
}
fetchPages() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, this.props.query)
.then((res) => 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 (
<div className="flex items-center justify-between my-1 text-sm" key={page.name}>
@ -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}
>
<span className="flex px-2 py-1.5 group dark:text-gray-300 z-9 relative break-all">
<Link
@ -62,7 +67,8 @@ export default class ExitPages extends React.Component {
</a>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(page.count)}</span>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.conversion_rate)}%</span>}
</div>
)
}
@ -73,7 +79,10 @@ export default class ExitPages extends React.Component {
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Page url</span>
<span>Unique Exits</span>
<div className="text-right">
<span className="inline-block w-20">Unique Exits</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove>

View File

@ -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 (
<div
@ -49,7 +54,7 @@ export default class Visits 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}
>
<span
className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all"
@ -69,7 +74,8 @@ export default class Visits extends React.Component {
</a>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(page.count)}</span>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(page.conversion_rate)}%</span>}
</div>
)
}
@ -89,7 +95,10 @@ export default class Visits extends React.Component {
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Page url</span>
<span>{ this.label() }</span>
<div className="text-right">
<span className="inline-block w-20">{ this.label() }</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove>

View File

@ -33,15 +33,21 @@ class AllSources extends React.Component {
return this.props.query.period === 'realtime'
}
showConversionRate() {
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 maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
return (
<div
className="flex items-center justify-between my-1 text-sm"
@ -51,7 +57,7 @@ class AllSources extends React.Component {
count={referrer.count}
all={this.state.referrers}
bg="bg-blue-50 dark:bg-gray-500 dark:bg-opacity-15"
maxWidthDeduction="4rem"
maxWidthDeduction={maxWidthDeduction}
>
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
<Link
@ -66,7 +72,8 @@ class AllSources extends React.Component {
</Link>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(referrer.count)}</span>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(referrer.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{referrer.conversion_rate}%</span>}
</div>
)
}
@ -81,7 +88,10 @@ class AllSources extends React.Component {
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500">
<span>Source</span>
<span>{this.label()}</span>
<div className="text-right">
<span className="inline-block w-20">{this.label()}</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove className="flex-grow">
@ -149,6 +159,10 @@ class UTMSources extends React.Component {
return this.props.query.period === 'realtime'
}
showConversionRate() {
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 +172,7 @@ class UTMSources extends React.Component {
renderReferrer(referrer) {
const query = new URLSearchParams(window.location.search)
query.set(this.props.tab, referrer.name)
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
return (
<div
@ -168,7 +183,7 @@ class UTMSources extends React.Component {
count={referrer.count}
all={this.state.referrers}
bg="bg-blue-50 dark:bg-gray-500 dark:bg-opacity-15"
maxWidthDeduction="4rem"
maxWidthDeduction={maxWidthDeduction}
>
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
@ -180,7 +195,8 @@ class UTMSources extends React.Component {
</Link>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">{numberFormatter(referrer.count)}</span>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(referrer.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{referrer.conversion_rate}%</span>}
</div>
)
}
@ -195,7 +211,10 @@ class UTMSources extends React.Component {
<div className="flex flex-col flex-grow">
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>{UTM_TAGS[this.props.tab].label}</span>
<span>{this.label()}</span>
<div className="text-right">
<span className="inline-block w-20">{this.label()}</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove className="flex-grow">

View File

@ -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)
@ -332,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)
@ -345,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)
@ -358,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"])
@ -395,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"])
@ -412,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
@ -425,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
@ -438,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
@ -451,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
@ -464,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
@ -580,4 +593,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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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", %{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 +310,14 @@ 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 +341,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