Evaluate ephemeral funnels before saving (#3097)

* PoC: evaluate funnel on step selection

* Adjust test to actually _select_ steps

* Throttle and test ephemeral funnel evaluation

* Format compact percentages server-side

At this point it's pointless to duplicate this;
all the primitives exist in the API in case the client
wants a different representation.
This commit is contained in:
hq1 2023-07-04 12:07:16 +02:00 committed by GitHub
parent e98a16141e
commit ade437c085
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 235 additions and 99 deletions

View File

@ -56,7 +56,7 @@ export default function FunnelTooltip(palette, funnel) {
</td>
<td class="text-right text-sm">
<span>
${dataIndex == 0 ? formatPercentage(funnel.entering_visitors_percentage) : formatPercentage(currentStep.conversion_rate_step)}%
${dataIndex == 0 ? funnel.entering_visitors_percentage : currentStep.conversion_rate_step}%
</span>
</td>
</tr>
@ -73,7 +73,7 @@ export default function FunnelTooltip(palette, funnel) {
<span>${dataIndex == 0 ? funnel.never_entering_visitors.toLocaleString() : currentStep.dropoff.toLocaleString()}</span>
</td >
<td class="text-right text-sm">
<span>${dataIndex == 0 ? formatPercentage(funnel.never_entering_visitors_percentage) : formatPercentage(currentStep.dropoff_percentage)}%</span>
<span>${dataIndex == 0 ? funnel.never_entering_visitors_percentage : currentStep.dropoff_percentage}%</span>
</td>
</tr >
</table >
@ -83,8 +83,3 @@ export default function FunnelTooltip(palette, funnel) {
tooltipEl.style.display = null
}
}
const formatPercentage = (value) => {
const decimalNumber = parseFloat(value);
return decimalNumber % 1 === 0 ? decimalNumber.toFixed(0) : value;
}

View File

@ -97,7 +97,7 @@ export default function Funnel(props) {
const formatDataLabel = (visitors, ctx) => {
if (ctx.dataset.label === 'Visitors') {
const conversionRate = funnel.steps[ctx.dataIndex].conversion_rate
return `${formatPercentage(conversionRate)}% \n(${numberFormatter(visitors)} Visitors)`
return `${conversionRate}% \n(${numberFormatter(visitors)} Visitors)`
} else {
return null
}
@ -339,11 +339,6 @@ export default function Funnel(props) {
)
}
const formatPercentage = (value) => {
const decimalNumber = parseFloat(value);
return decimalNumber % 1 === 0 ? decimalNumber.toFixed(0) : value;
}
return (
<div style={{ minHeight: '400px' }}>
<LazyLoader onVisible={() => setVisible(true)}>

View File

@ -169,14 +169,22 @@ defmodule Plausible.Stats.Funnel do
end
defp percentage(x, y) when x in [0, nil] or y in [0, nil] do
"0.00"
"0"
end
defp percentage(x, y) do
x
|> Decimal.div(y)
|> Decimal.mult(100)
|> Decimal.round(2)
|> Decimal.to_string()
result =
x
|> Decimal.div(y)
|> Decimal.mult(100)
|> Decimal.round(2)
|> Decimal.to_string()
case result do
<<compact::binary-size(1), ".00">> -> compact
<<compact::binary-size(2), ".00">> -> compact
<<compact::binary-size(3), ".00">> -> compact
decimal -> decimal
end
end
end

View File

@ -36,14 +36,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
form: to_form(Plausible.Funnels.create_changeset(site, "", [])),
goals: goals,
site: site,
selections_made: Map.new()
selections_made: Map.new(),
evaluation_result: nil,
evaluation_at: System.monotonic_time()
)}
end
def render(assigns) do
~H"""
<div id="funnel-form" class="grid grid-cols-4 gap-6 mt-6">
<div class="col-span-4 sm:col-span-2">
<div id="funnel-form" class="grid grid-cols-2 gap-6 mt-6">
<div class="col-span-2 sm:col-span-2">
<.form
:let={f}
for={@form}
@ -63,7 +65,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
) %>
<div :for={step_idx <- @step_ids} class="flex">
<div class="w-full flex-1">
<div class="w-2/5 flex-1">
<.live_component
submit_name="funnel[steps][][goal_id]"
module={PlausibleWeb.Live.FunnelSettings.ComboBox}
@ -72,7 +74,17 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
/>
</div>
<.remove_step_button :if={length(@step_ids) > Funnel.min_steps()} step_idx={step_idx} />
<div class="w-min inline-flex items-center align-middle">
<.remove_step_button :if={length(@step_ids) > Funnel.min_steps()} step_idx={step_idx} />
</div>
<div class="w-2/5 inline-flex items-center ml-2 mb-3 text-gray-500 dark:text-gray-400">
<.evaluation
:if={@evaluation_result}
result={@evaluation_result}
at={Enum.find_index(@step_ids, &(&1 == step_idx))}
/>
</div>
</div>
<.add_step_button :if={
@ -81,7 +93,19 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
} />
<div class="mt-6">
<%= if has_steps_errors?(f) do %>
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
<%= if @evaluation_result do %>
Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
<% else %>
<span class="text-red-600 text-sm">
Choose minimum <%= Funnel.min_steps() %> steps to evaluate funnel.
</span>
<% end %>
</p>
</div>
<div class="mt-6">
<%= if has_steps_errors?(f) or map_size(@selections_made) < Funnel.min_steps() or length(@step_ids) > map_size(@selections_made) do %>
<.submit_button_inactive />
<% else %>
<.submit_button />
@ -108,7 +132,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
name={@field.name}
value={@field.value}
phx-debounce="300"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-full rounded-md sm:text-sm border-gray-300 dark:border-gray-500"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500"
/>
<.error :for={{msg, _} <- @field.errors}>Funnel name <%= msg %></.error>
@ -128,7 +152,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def remove_step_button(assigns) do
~H"""
<div class="inline-flex items-center ml-2 mb-2 text-red-600">
<div class="inline-flex items-center ml-2 mb-4 text-red-600">
<svg
id={"remove-step-#{@step_idx}"}
class="feather feather-sm cursor-pointer"
@ -192,6 +216,28 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
"""
end
attr(:at, :integer, required: true)
attr(:result, :map, required: true)
def evaluation(assigns) do
~H"""
<span class="text-xs" id={"step-eval-#{@at}"}>
<% step = Enum.at(@result.steps, @at) %>
<span :if={step && @at == 0}>
<span
class="border-dotted border-b border-gray-400 "
tooltip="Sample calculation for last month"
>
Entering Visitors: <strong><%= @result.entering_visitors %></strong>
</span>
</span>
<span :if={step && @at > 0}>
Dropoff: <strong><%= Map.get(step, :dropoff_percentage) %>%</strong>
</span>
</span>
"""
end
def handle_event("add-step", _value, socket) do
step_ids = socket.assigns.step_ids
@ -208,6 +254,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
idx = String.to_integer(idx)
step_ids = List.delete(socket.assigns.step_ids, idx)
selections_made = drop_selection(socket.assigns.selections_made, idx)
send(self(), :evaluate_funnel)
{:noreply, assign(socket, step_ids: step_ids, selections_made: selections_made)}
end
@ -245,12 +292,72 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
selections_made = store_selection(socket.assigns, combo_box, goal_id)
send(self(), :evaluate_funnel)
{:noreply,
assign(socket,
selections_made: selections_made
)}
end
def handle_info(:evaluate_funnel, socket) do
{:noreply, evaluate_funnel(socket)}
end
defp evaluate_funnel(%{assigns: %{selections_made: selections_made}} = socket)
when map_size(selections_made) < Funnel.min_steps() do
socket
end
defp evaluate_funnel(
%{
assigns: %{
site: site,
selections_made: selections_made,
evaluation_at: evaluation_at
}
} = socket
) do
with true <- seconds_since_evaluation(evaluation_at) >= 1,
{:ok, {definition, query}} <- build_ephemeral_funnel(site, selections_made),
{:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do
assign(socket, evaluation_result: funnel, evaluation_at: System.monotonic_time())
else
_ ->
socket
end
end
defp seconds_since_evaluation(evaluation_at) do
System.convert_time_unit(System.monotonic_time() - evaluation_at, :native, :second)
end
defp build_ephemeral_funnel(site, selections_made) do
steps =
selections_made
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {_, goal} ->
%{
"goal_id" => goal.id,
"goal" => %{
"id" => goal.id,
"event_name" => goal.event_name,
"page_path" => goal.page_path
}
}
end)
definition =
Plausible.Funnels.ephemeral_definition(
site,
"Test funnel",
steps
)
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
{:ok, {definition, query}}
end
defp find_sequence_break(input) do
input
|> Enum.sort()

View File

@ -178,26 +178,26 @@ defmodule Plausible.FunnelsTest do
%{
label: "Visit /go/to/blog/**",
visitors: 2,
conversion_rate: "100.00",
conversion_rate_step: "0.00",
conversion_rate: "100",
conversion_rate_step: "0",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
},
%{
label: "Signup",
visitors: 2,
conversion_rate: "100.00",
conversion_rate_step: "100.00",
conversion_rate: "100",
conversion_rate_step: "100",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
},
%{
label: "Visit /checkout",
visitors: 1,
conversion_rate: "50.00",
conversion_rate_step: "50.00",
conversion_rate: "50",
conversion_rate_step: "50",
dropoff: 1,
dropoff_percentage: "50.00"
dropoff_percentage: "50"
}
]
}} = funnel_data
@ -230,32 +230,32 @@ defmodule Plausible.FunnelsTest do
%{
all_visitors: 2,
entering_visitors: 2,
entering_visitors_percentage: "100.00",
entering_visitors_percentage: "100",
never_entering_visitors: 0,
never_entering_visitors_percentage: "0.00",
never_entering_visitors_percentage: "0",
steps: [
%{
label: "Visit /go/to/blog/**",
visitors: 2,
conversion_rate: "100.00",
conversion_rate_step: "0.00",
conversion_rate: "100",
conversion_rate_step: "0",
dropoff: 0
},
%{
label: "Signup",
visitors: 2,
conversion_rate: "100.00",
conversion_rate_step: "100.00",
conversion_rate: "100",
conversion_rate_step: "100",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
},
%{
label: "Visit /checkout",
visitors: 1,
conversion_rate: "50.00",
conversion_rate_step: "50.00",
conversion_rate: "50",
conversion_rate_step: "50",
dropoff: 1,
dropoff_percentage: "50.00"
dropoff_percentage: "50"
}
]
}} = funnel_data
@ -280,33 +280,33 @@ defmodule Plausible.FunnelsTest do
%{
all_visitors: 0,
entering_visitors: 0,
entering_visitors_percentage: "0.00",
entering_visitors_percentage: "0",
never_entering_visitors: 0,
never_entering_visitors_percentage: "0.00",
never_entering_visitors_percentage: "0",
steps: [
%{
label: "Visit /go/to/blog/**",
visitors: 0,
conversion_rate: "0.00",
conversion_rate_step: "0.00",
conversion_rate: "0",
conversion_rate_step: "0",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
},
%{
label: "Signup",
visitors: 0,
conversion_rate: "0.00",
conversion_rate_step: "0.00",
conversion_rate: "0",
conversion_rate_step: "0",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
},
%{
label: "Visit /checkout",
visitors: 0,
conversion_rate: "0.00",
conversion_rate_step: "0.00",
conversion_rate: "0",
conversion_rate_step: "0",
dropoff: 0,
dropoff_percentage: "0.00"
dropoff_percentage: "0"
}
]
}} = funnel_data

View File

@ -41,34 +41,34 @@ defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
"never_entering_visitors_percentage" => "33.33",
"steps" => [
%{
"conversion_rate" => "100.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "100",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Visit /blog/announcement",
"visitors" => 2
},
%{
"conversion_rate" => "100.00",
"conversion_rate_step" => "100.00",
"conversion_rate" => "100",
"conversion_rate_step" => "100",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Signup",
"visitors" => 2
},
%{
"conversion_rate" => "50.00",
"conversion_rate_step" => "50.00",
"conversion_rate" => "50",
"conversion_rate_step" => "50",
"dropoff" => 1,
"dropoff_percentage" => "50.00",
"dropoff_percentage" => "50",
"label" => "Visit /cart/add/product",
"visitors" => 1
},
%{
"conversion_rate" => "50.00",
"conversion_rate_step" => "100.00",
"conversion_rate" => "50",
"conversion_rate_step" => "100",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Purchase",
"visitors" => 1
}
@ -128,39 +128,39 @@ defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
"name" => "Test funnel",
"all_visitors" => 1,
"entering_visitors" => 1,
"entering_visitors_percentage" => "100.00",
"entering_visitors_percentage" => "100",
"never_entering_visitors" => 0,
"never_entering_visitors_percentage" => "0.00",
"never_entering_visitors_percentage" => "0",
"steps" => [
%{
"conversion_rate" => "100.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "100",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Visit /blog/announcement",
"visitors" => 1
},
%{
"conversion_rate" => "100.00",
"conversion_rate_step" => "100.00",
"conversion_rate" => "100",
"conversion_rate_step" => "100",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Signup",
"visitors" => 1
},
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 1,
"dropoff_percentage" => "100.00",
"dropoff_percentage" => "100",
"label" => "Visit /cart/add/product",
"visitors" => 0
},
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Purchase",
"visitors" => 0
}
@ -180,39 +180,39 @@ defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
"name" => "Test funnel",
"all_visitors" => 0,
"entering_visitors" => 0,
"entering_visitors_percentage" => "0.00",
"entering_visitors_percentage" => "0",
"never_entering_visitors" => 0,
"never_entering_visitors_percentage" => "0.00",
"never_entering_visitors_percentage" => "0",
"steps" => [
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Visit /blog/announcement",
"visitors" => 0
},
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Signup",
"visitors" => 0
},
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Visit /cart/add/product",
"visitors" => 0
},
%{
"conversion_rate" => "0.00",
"conversion_rate_step" => "0.00",
"conversion_rate" => "0",
"conversion_rate_step" => "0",
"dropoff" => 0,
"dropoff_percentage" => "0.00",
"dropoff_percentage" => "0",
"label" => "Purchase",
"visitors" => 0
}

View File

@ -164,10 +164,14 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
assert lv = find_live_child(lv, "funnels-form")
lv
|> element("li#dropdown-step-1-option-0 a")
|> render_click()
doc =
lv
|> element("form")
|> render_change(%{funnel: %{name: "My test funnel"}})
|> element("li#dropdown-step-2-option-0 a")
|> render_click()
save_inactive = ~s/form button#save.cursor-not-allowed/
save_active = ~s/form button#save[type="submit"]/
@ -192,6 +196,33 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
refute element_exists?(doc, save_inactive)
end
test "funnel gets evaluated on every select, assuming a second has passed between selections",
%{
conn: conn,
site: site
} do
setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
assert lv = find_live_child(lv, "funnels-form")
lv |> element("li#dropdown-step-1-option-0 a") |> render_click()
:timer.sleep(1001)
lv |> element("li#dropdown-step-2-option-0 a") |> render_click()
doc = lv |> element("#step-eval-0") |> render()
assert text_of_element(doc, ~s/#step-eval-0/) =~ "Entering Visitors: 0"
doc = lv |> element("#step-eval-1") |> render()
assert text_of_element(doc, ~s/#step-eval-1/) =~ "Dropoff: 0%"
doc = lv |> element("#funnel-eval") |> render()
assert text_of_element(doc, ~s/#funnel-eval/) =~ "Last month conversion rate: 0%"
end
test "cancel buttons renders the funnel list", %{
conn: conn,
site: site