mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
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:
parent
26373b2726
commit
8ac166b447
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}%`
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user