Add revenue metrics to Properties report (#3209)

* extract add_exit_rate function

* Change internal API metric names for /entry-pages & /exit-pages

* `unique_entrances` -> `visitors`,
* `total_entrances` -> `visits`,
* `unique_exits` -> `visitors`,
* `total_exits` -> `visits`,

This is just a consistency improvement - the `visitors` metric always means
one thing and there's no need to call it different for entry pages internally.

This commit does not change any noticable behavior. The UI labels are kept
the same and the column headers in the CSV export will also remain the same.

* Change internal API metric names for /conversions

* `unique_conversions` -> `visitors`,
* `total_conversions` -> `events`,

* return revenue metrics from /custom-prop-values (backend)

* be more explicit about which metric is plotted with Bar

* validate that ListReport input metrics actually exist in the API response

* display revenue metrics in the dashboard Properties section

* limit the number of columns shown on mobile

* add revenue metrics to Properties > Details

* review suggestions

* define hiddenOnMobile per metric instead of keeping the first 3

* rewrite if-else block

---------

Co-authored-by: Vini Brasil <vini@hey.com>
This commit is contained in:
RobertJoonas 2023-08-01 13:52:31 +01:00 committed by GitHub
parent 26373b2726
commit 8ac166b447
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 224 additions and 23 deletions

View File

@ -43,9 +43,11 @@ export default function Properties(props) {
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={[
{name: 'visitors', label: 'Visitors'},
{name: 'events', label: 'Events'},
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC
{name: 'visitors', label: 'Visitors', plot: true},
{name: 'events', label: 'Events', hiddenOnMobile: true},
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
]}
detailsLink={url.sitePath(site, `/custom-prop-values/${propKey}`)}
query={query}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal'
import * as api from '../../api'
@ -53,7 +54,7 @@ function PropsModal(props) {
return searchParams.toString()
}
function renderListItem(listItem) {
function renderListItem(listItem, hasRevenue) {
return (
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2">
@ -63,10 +64,12 @@ function PropsModal(props) {
{listItem.name}
</Link>
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(listItem.events)}</td>
{ query.filters.goal && <td className="p-2 w-32 font-medium" align="right">{listItem.conversion_rate}%</td> }
{ !query.filters.goal && <td className="p-2 w-32 font-medium" align="right">{listItem.percentage}</td> }
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
{ query.filters.goal && <td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td> }
{ !query.filters.goal && <td className="p-2 w-24 font-medium" align="right">{listItem.percentage}</td> }
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue}/></td> }
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue}/></td> }
</tr>
)
}
@ -76,6 +79,8 @@ function PropsModal(props) {
}
function renderBody() {
const hasRevenue = list.some((prop) => prop.total_revenue)
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">Custom Property breakdown</h1>
@ -86,13 +91,15 @@ function PropsModal(props) {
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">{propKey}</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Events</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{query.filters.goal ? 'CR' : '%'}</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Events</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{query.filters.goal ? 'CR' : '%'}</th>
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
</tr>
</thead>
<tbody>
{ list.map(renderListItem) }
{ list.map((item) => renderListItem(item, hasRevenue)) }
</tbody>
</table>
</main>

View File

@ -139,6 +139,23 @@ export default function ListReport(props) {
return () => { document.removeEventListener('tick', fetchData) }
}, [props.keyLabel, props.query, visible]);
// returns a filtered `metrics` list. Since currently, the backend can return different
// metrics based on filters and existing data, this function validates that the metrics
// we want to display are actually there in the API response.
function getAvailableMetrics() {
return metrics.filter((metric) => {
return state.list.some((listItem) => listItem[metric.name] != null)
})
}
function hiddenOnMobileClass(metric) {
if (metric.hiddenOnMobile) {
return 'hidden md:block'
} else {
return ''
}
}
function renderReport() {
if (state.list && state.list.length > 0) {
return (
@ -159,8 +176,16 @@ export default function ListReport(props) {
}
function renderReportHeader() {
const metricLabels = metrics.map((metric) => {
return (<span key={metric.name} className="text-right" style={{minWidth: colMinWidth}}>{ metricLabelFor(metric, props.query) }</span>)
const metricLabels = getAvailableMetrics().map((metric) => {
return (
<div
key={metric.name}
className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{minWidth: colMinWidth}}
>
{ metricLabelFor(metric, props.query) }
</div>
)
})
return (
@ -205,7 +230,7 @@ export default function ListReport(props) {
function renderBarFor(listItem) {
const lightBackground = props.color || 'bg-green-50'
const noop = () => {}
const metricToPlot = metrics[0].name
const metricToPlot = metrics.find(m => m.plot).name
return (
<div className="flex-grow w-full overflow-hidden">
@ -241,9 +266,13 @@ export default function ListReport(props) {
}
function renderMetricValuesFor(listItem) {
return metrics.map((metric) => {
return getAvailableMetrics().map((metric) => {
return (
<div key={`${listItem.name}__${metric.name}`} style={{width: colMinWidth, minWidth: colMinWidth}} className="text-right">
<div
key={`${listItem.name}__${metric.name}`}
className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{width: colMinWidth, minWidth: colMinWidth}}
>
<span className="font-medium text-sm dark:text-gray-200 text-right">
{ displayMetricValue(listItem[metric.name], metric) }
</span>

View File

@ -1,11 +1,13 @@
import numberFormatter from "../../util/number-formatter"
import React from "react"
import Money from "../behaviours/money"
export const VISITORS_METRIC = {
name: 'visitors',
label: 'Visitors',
realtimeLabel: 'Current visitors',
goalFilterLabel: 'Conversions'
goalFilterLabel: 'Conversions',
plot: true
}
export const PERCENTAGE_METRIC = { name: 'percentage', label: '%' }
export const CR_METRIC = { name: 'conversion_rate', label: 'CR' }
@ -23,7 +25,9 @@ export function maybeWithCR(metrics, query) {
}
export function displayMetricValue(value, metric) {
if (metric === PERCENTAGE_METRIC) {
if (['total_revenue', 'average_revenue'].includes(metric.name)) {
return <Money formatted={value} />
} else if (metric === PERCENTAGE_METRIC) {
return value
} else if (metric === CR_METRIC) {
return `${value}%`

View File

@ -1206,18 +1206,28 @@ defmodule PlausibleWeb.Api.StatsController do
end
defp breakdown_custom_prop_values(site, %{"prop_key" => prop_key} = params) do
pagination = parse_pagination(params)
prefixed_prop = "event:props:" <> prop_key
query =
Query.from(site, params)
|> Filters.add_prefix()
|> Map.put(:include_imported, false)
pagination = parse_pagination(params)
prefixed_prop = "event:props:" <> prop_key
metrics =
if Map.has_key?(query.filters, "event:goal") do
[:visitors, :events, :average_revenue, :total_revenue]
else
[:visitors, :events]
end
props =
Stats.breakdown(site, query, prefixed_prop, [:visitors, :events], pagination)
Stats.breakdown(site, query, prefixed_prop, metrics, pagination)
|> transform_keys(%{prop_key => :name})
|> Enum.map(fn entry ->
Enum.map(entry, &format_revenue_metric/1)
|> Map.new()
end)
|> add_percentages(site, query)
if Map.has_key?(query.filters, "event:goal") do

View File

@ -775,6 +775,155 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
}
]
end
test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do
prop_key = "logged_in"
populate_stats(site, [
build(:event,
name: "Payment",
"meta.key": [prop_key],
"meta.value": ["true"],
revenue_reporting_amount: Decimal.new("12"),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "Payment",
"meta.key": [prop_key],
"meta.value": ["true"],
revenue_reporting_amount: Decimal.new("100"),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "Payment",
"meta.key": [prop_key],
"meta.value": ["false"],
revenue_reporting_amount: Decimal.new("8"),
revenue_reporting_currency: "EUR"
)
])
insert(:goal, %{site: site, event_name: "Payment", currency: :EUR})
filters = Jason.encode!(%{goal: "Payment"})
conn =
get(
conn,
"/api/stats/#{site.domain}/custom-prop-values/#{prop_key}?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"visitors" => 2,
"name" => "true",
"events" => 2,
"conversion_rate" => 66.7,
"total_revenue" => %{"long" => "€112.00", "short" => "€112.0"},
"average_revenue" => %{"long" => "€56.00", "short" => "€56.0"}
},
%{
"visitors" => 1,
"name" => "false",
"events" => 1,
"conversion_rate" => 33.3,
"total_revenue" => %{"long" => "€8.00", "short" => "€8.0"},
"average_revenue" => %{"long" => "€8.00", "short" => "€8.0"}
}
]
end
test "returns revenue metrics when filtering by many revenue goals with same currency", %{
conn: conn,
site: site
} do
prop_key = "logged_in"
insert(:goal, site: site, event_name: "Payment", currency: "EUR")
insert(:goal, site: site, event_name: "Payment2", currency: "EUR")
populate_stats(site, [
build(:event,
name: "Payment",
"meta.key": [prop_key],
"meta.value": ["false"],
revenue_reporting_amount: Decimal.new("10"),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "Payment",
"meta.key": [prop_key],
"meta.value": ["true"],
revenue_reporting_amount: Decimal.new("30"),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "Payment2",
"meta.key": [prop_key],
"meta.value": ["true"],
revenue_reporting_amount: Decimal.new("50"),
revenue_reporting_currency: "EUR"
)
])
filters = Jason.encode!(%{goal: "Payment|Payment2"})
conn =
get(
conn,
"/api/stats/#{site.domain}/custom-prop-values/#{prop_key}?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"visitors" => 2,
"name" => "true",
"events" => 2,
"conversion_rate" => 66.7,
"total_revenue" => %{"long" => "€80.00", "short" => "€80.0"},
"average_revenue" => %{"long" => "€40.00", "short" => "€40.0"}
},
%{
"visitors" => 1,
"name" => "false",
"events" => 1,
"conversion_rate" => 33.3,
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0"}
}
]
end
test "does not return revenue metrics when filtering by many revenue goals with different currencies",
%{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
populate_stats(site, [
build(:event,
name: "Payment",
"meta.key": ["logged_in"],
"meta.value": ["false"],
revenue_reporting_amount: Decimal.new("10"),
revenue_reporting_currency: "EUR"
)
])
filters = Jason.encode!(%{goal: "Payment|AddToCart"})
conn =
get(
conn,
"/api/stats/#{site.domain}/custom-prop-values/whatever-prop?period=day&filters=#{filters}"
)
returned_metrics =
json_response(conn, 200)
|> List.first()
|> Map.keys()
refute "Average revenue" in returned_metrics
refute "Total revenue" in returned_metrics
end
end
describe "GET /api/stats/:domain/custom-prop-values/:prop_key - other filters" do