From c98fd1863a73c1a50c4a2609eb9581344ea21150 Mon Sep 17 00:00:00 2001 From: Marko Saric Date: Mon, 24 Aug 2026 16:50:19 +0200 Subject: [PATCH] trial prospect additions --- .../customer_support/trial_prospect.ex | 1 + .../customer_support/trial_prospects.ex | 44 +++-- .../workers/score_trial_prospects.ex | 2 +- .../live/customer_support/trial_prospects.ex | 151 ++++++++++++++++-- ...000_add_reviewed_at_to_trial_prospects.exs | 9 ++ .../customer_support/trial_prospects_test.exs | 64 ++++++++ test/workers/score_trial_prospects_test.exs | 15 ++ 7 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 priv/repo/migrations/20260824120000_add_reviewed_at_to_trial_prospects.exs diff --git a/extra/lib/plausible/customer_support/trial_prospect.ex b/extra/lib/plausible/customer_support/trial_prospect.ex index 45c5dfdd2a30..f4129fbb9125 100644 --- a/extra/lib/plausible/customer_support/trial_prospect.ex +++ b/extra/lib/plausible/customer_support/trial_prospect.ex @@ -20,6 +20,7 @@ defmodule Plausible.CustomerSupport.TrialProspect do field :over_top_tier, :boolean, default: false field :estimated_mrr, :integer field :computed_at, :utc_datetime + field :reviewed_at, :utc_datetime timestamps() end diff --git a/extra/lib/plausible/customer_support/trial_prospects.ex b/extra/lib/plausible/customer_support/trial_prospects.ex index 998ed274a149..2a87c650c254 100644 --- a/extra/lib/plausible/customer_support/trial_prospects.ex +++ b/extra/lib/plausible/customer_support/trial_prospects.ex @@ -44,7 +44,7 @@ defmodule Plausible.CustomerSupport.TrialProspects do @kind_rank %{starter: 0, growth: 1, business: 2} @page_size 100 - @sortable_columns ~w(mrr trial_start) + @sortable_columns ~w(mrr traffic trial_start) @max_expired_days 30 @spec sortable_columns() :: [String.t()] @@ -67,14 +67,14 @@ defmodule Plausible.CustomerSupport.TrialProspects do ) end - @spec list(String.t(), :asc | :desc, pos_integer()) :: %{ + @spec list(String.t(), :asc | :desc, pos_integer(), String.t(), String.t()) :: %{ prospects: [TrialProspect.t()], page_number: pos_integer(), total_pages: pos_integer(), total_entries: non_neg_integer() } - def list(sort_by, sort_direction, page) do - base = listing_query() + def list(sort_by, sort_direction, page, trial_status, reviewed_status) do + base = listing_query(trial_status, reviewed_status) total_entries = Repo.aggregate(base, :count) total_pages = max(1, ceil(total_entries / @page_size)) @@ -96,12 +96,32 @@ defmodule Plausible.CustomerSupport.TrialProspects do } end - defp listing_query do - from(p in TrialProspect, - join: t in subquery(population_query()), - as: :team, - on: t.id == p.team_id - ) + defp listing_query(trial_status, reviewed_status) do + TrialProspect + |> join(:inner, [p], t in subquery(population_query()), as: :team, on: t.id == p.team_id) + |> filter_trial_status(trial_status) + |> filter_reviewed_status(reviewed_status) + end + + defp filter_trial_status(query, "all"), do: query + + defp filter_trial_status(query, _active) do + today = Date.utc_today() + from([team: t] in query, where: t.trial_expiry_date >= ^today) + end + + defp filter_reviewed_status(query, "all"), do: query + + defp filter_reviewed_status(query, _unreviewed) do + from([p] in query, where: is_nil(p.reviewed_at)) + end + + @spec mark_reviewed(pos_integer(), boolean()) :: {non_neg_integer(), nil} + def mark_reviewed(id, reviewed?) do + reviewed_at = if reviewed?, do: DateTime.utc_now(:second), else: nil + + from(p in TrialProspect, where: p.id == ^id) + |> Repo.update_all(set: [reviewed_at: reviewed_at]) end defp preload_team(q) do @@ -114,6 +134,10 @@ defmodule Plausible.CustomerSupport.TrialProspects do order_by(q, [team: t], [{^direction, t.inserted_at}]) end + defp order_prospects(q, "traffic", direction) do + order_by(q, [p], [{^direction, p.estimated_monthly}, {^direction, p.id}]) + end + # Over-the-top-tier (Custom/Enterprise) prospects rank first defp order_prospects(q, "mrr", direction) do order_by(q, [p], [{^direction, p.over_top_tier}, {^direction, p.estimated_mrr}]) diff --git a/extra/lib/plausible/workers/score_trial_prospects.ex b/extra/lib/plausible/workers/score_trial_prospects.ex index 0a47f6d5833a..fe46a1191f92 100644 --- a/extra/lib/plausible/workers/score_trial_prospects.ex +++ b/extra/lib/plausible/workers/score_trial_prospects.ex @@ -80,7 +80,7 @@ defmodule Plausible.Workers.ScoreTrialProspects do defp upsert(row) do Repo.insert_all(TrialProspect, [row], - on_conflict: {:replace_all_except, [:id, :team_id, :inserted_at]}, + on_conflict: {:replace_all_except, [:id, :team_id, :inserted_at, :reviewed_at]}, conflict_target: :team_id ) end diff --git a/extra/lib/plausible_web/live/customer_support/trial_prospects.ex b/extra/lib/plausible_web/live/customer_support/trial_prospects.ex index 872bb025531f..910901212cad 100644 --- a/extra/lib/plausible_web/live/customer_support/trial_prospects.ex +++ b/extra/lib/plausible_web/live/customer_support/trial_prospects.ex @@ -24,14 +24,32 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do _ -> :desc end + trial_status = if params["trial_status"] == "all", do: "all", else: "active" + reviewed_status = if params["reviewed_status"] == "all", do: "all", else: "unreviewed" + page = parse_page(params["page"]) + socket = socket - |> assign(sort_by: sort_by, sort_direction: sort_direction) - |> assign(TrialProspects.list(sort_by, sort_direction, parse_page(params["page"]))) + |> assign( + sort_by: sort_by, + sort_direction: sort_direction, + trial_status: trial_status, + reviewed_status: reviewed_status, + page_number: page + ) + |> load_prospects() {:noreply, socket} end + def handle_event("set-reviewed", %{"id" => id, "reviewed" => reviewed}, socket) do + with {id, ""} <- Integer.parse(id) do + TrialProspects.mark_reviewed(id, reviewed == "true") + end + + {:noreply, load_prospects(socket)} + end + def render(assigns) do ~H""" @@ -42,8 +60,39 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do

+
+
+ <.filter_link + active={@trial_status == "active"} + patch={index_path(assigns, trial_status: "active", page: 1)} + > + Active trials + + <.filter_link + active={@trial_status == "all"} + patch={index_path(assigns, trial_status: "all", page: 1)} + > + Include recently expired + +
+
+ <.filter_link + active={@reviewed_status == "unreviewed"} + patch={index_path(assigns, reviewed_status: "unreviewed", page: 1)} + > + Unreviewed only + + <.filter_link + active={@reviewed_status == "all"} + patch={index_path(assigns, reviewed_status: "all", page: 1)} + > + Include reviewed + +
+
+
- <.table rows={@prospects}> + <.table rows={@prospects} row_attrs={&row_attrs/1}> <:thead> <.th>Team <.sort_th @@ -51,17 +100,29 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do by="mrr" sort_by={@sort_by} sort_direction={@sort_direction} + trial_status={@trial_status} + reviewed_status={@reviewed_status} /> <.th>Feature tier <.th>Forced by - <.th>Traffic estimate + <.sort_th + label="Traffic estimate" + by="traffic" + sort_by={@sort_by} + sort_direction={@sort_direction} + trial_status={@trial_status} + reviewed_status={@reviewed_status} + /> <.sort_th label="Trial start" by="trial_start" sort_by={@sort_by} sort_direction={@sort_direction} + trial_status={@trial_status} + reviewed_status={@reviewed_status} /> <.th>Trial expiry + <.th>Review <:tbody :let={p}> <.td> @@ -89,6 +150,25 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do <.td>{StatsView.large_number_format(p.estimated_monthly)} <.td>{format_date(trial_start(p.team))} <.td>{format_date(p.team.trial_expiry_date)} + <.td> + + @@ -100,6 +180,8 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do Routes.customer_support_trial_prospects_path(PlausibleWeb.Endpoint, :index, sort_by: @sort_by, sort_direction: @sort_direction, + trial_status: @trial_status, + reviewed_status: @reviewed_status, page: @page_number ) ) @@ -123,6 +205,8 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do attr :by, :string, required: true attr :sort_by, :string, required: true attr :sort_direction, :atom, required: true + attr :trial_status, :string, required: true + attr :reviewed_status, :string, required: true defp sort_th(assigns) do active = assigns.sort_by == assigns.by @@ -131,12 +215,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do ~H""" <.th> <.link - patch={ - Routes.customer_support_trial_prospects_path(PlausibleWeb.Endpoint, :index, - sort_by: @by, - sort_direction: @next_direction - ) - } + patch={index_path(assigns, sort_by: @by, sort_direction: @next_direction, page: 1)} class="cursor-pointer select-none inline-flex items-center gap-1 hover:text-indigo-600 dark:hover:text-indigo-400" > {@label} @@ -146,6 +225,58 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do """ end + attr :active, :boolean, required: true + attr :patch, :string, required: true + slot :inner_block, required: true + + defp filter_link(assigns) do + ~H""" + <.link + patch={@patch} + class={[ + "border border-gray-200 px-3 py-2 text-sm first:rounded-l-md last:rounded-r-md dark:border-gray-700", + if(@active, + do: "bg-indigo-600 text-white dark:bg-indigo-500", + else: + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-750" + ) + ]} + > + {render_slot(@inner_block)} + + """ + end + + defp load_prospects(socket) do + assign( + socket, + TrialProspects.list( + socket.assigns.sort_by, + socket.assigns.sort_direction, + socket.assigns.page_number, + socket.assigns.trial_status, + socket.assigns.reviewed_status + ) + ) + end + + defp index_path(assigns, overrides) do + params = + [ + sort_by: assigns.sort_by, + sort_direction: assigns.sort_direction, + trial_status: assigns.trial_status, + reviewed_status: assigns.reviewed_status, + page: Map.get(assigns, :page_number, 1) + ] + |> Keyword.merge(overrides) + + Routes.customer_support_trial_prospects_path(PlausibleWeb.Endpoint, :index, params) + end + + defp row_attrs(%{reviewed_at: nil}), do: %{} + defp row_attrs(_reviewed), do: %{class: "opacity-60"} + defp next_direction(%{sort_direction: :desc}, true), do: :asc defp next_direction(_assigns, _active), do: :desc diff --git a/priv/repo/migrations/20260824120000_add_reviewed_at_to_trial_prospects.exs b/priv/repo/migrations/20260824120000_add_reviewed_at_to_trial_prospects.exs new file mode 100644 index 000000000000..ce8281c54fbc --- /dev/null +++ b/priv/repo/migrations/20260824120000_add_reviewed_at_to_trial_prospects.exs @@ -0,0 +1,9 @@ +defmodule Plausible.Repo.Migrations.AddReviewedAtToTrialProspects do + use Ecto.Migration + + def change do + alter table(:trial_prospects) do + add :reviewed_at, :utc_datetime + end + end +end diff --git a/test/plausible_web/live/customer_support/trial_prospects_test.exs b/test/plausible_web/live/customer_support/trial_prospects_test.exs index ace311724a36..d7e57653f1e9 100644 --- a/test/plausible_web/live/customer_support/trial_prospects_test.exs +++ b/test/plausible_web/live/customer_support/trial_prospects_test.exs @@ -127,6 +127,70 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspectsTest do assert text_of_element(html, "tbody tr:first-child") =~ "Older Co" end + test "can order by traffic estimate", %{conn: conn} do + low_traffic = insert(:team, name: "Low Traffic Co") + high_traffic = insert(:team, name: "High Traffic Co") + + prospect(low_traffic, estimated_monthly: 10_000) + prospect(high_traffic, estimated_monthly: 1_000_000) + + {:ok, _lv, html} = + live(conn, open_prospects(sort_by: "traffic", sort_direction: "desc")) + + assert text_of_element(html, "tbody tr:first-child") =~ "High Traffic Co" + end + + test "shows active trials by default and can include recently expired trials", %{conn: conn} do + active = + insert(:team, + name: "Active Co", + trial_expiry_date: Date.add(Date.utc_today(), 1) + ) + + expired = + insert(:team, + name: "Recently Expired Co", + trial_expiry_date: Date.add(Date.utc_today(), -1) + ) + + prospect(active, estimated_mrr: 19) + prospect(expired, estimated_mrr: 99) + + {:ok, _lv, active_html} = live(conn, open_prospects()) + assert text(active_html) =~ "Active Co" + refute text(active_html) =~ "Recently Expired Co" + + {:ok, _lv, all_html} = live(conn, open_prospects(trial_status: "all")) + assert text(all_html) =~ "Active Co" + assert text(all_html) =~ "Recently Expired Co" + end + + test "marks prospects reviewed and hides them from the default list", %{conn: conn} do + team = insert(:team, name: "Reviewed Co") + prospect = prospect(team, estimated_mrr: 99) + + {:ok, lv, _html} = live(conn, open_prospects()) + + lv + |> element(~s|button[phx-click="set-reviewed"][phx-value-id="#{prospect.id}"]|) + |> render_click() + + assert Repo.reload!(prospect).reviewed_at + refute has_element?(lv, "tbody", "Reviewed Co") + + {:ok, reviewed_lv, reviewed_html} = + live(conn, open_prospects(reviewed_status: "all")) + + assert text(reviewed_html) =~ "Reviewed Co" + assert text_of_element(reviewed_html, "tbody tr:first-child") =~ "Reviewed" + + reviewed_lv + |> element(~s|button[phx-click="set-reviewed"][phx-value-id="#{prospect.id}"]|) + |> render_click() + + refute Repo.reload!(prospect).reviewed_at + end + test "excludes rows left behind for teams no longer on a trial", %{conn: conn} do live_team = insert(:team, name: "Live Co") prospect(live_team, estimated_mrr: 19) diff --git a/test/workers/score_trial_prospects_test.exs b/test/workers/score_trial_prospects_test.exs index 2de1eeda006c..9eacf5a45158 100644 --- a/test/workers/score_trial_prospects_test.exs +++ b/test/workers/score_trial_prospects_test.exs @@ -305,6 +305,21 @@ defmodule Plausible.Workers.ScoreTrialProspectsTest do Repo.all(from p in TrialProspect, where: p.team_id == ^team_of(user).id) end + test "preserves reviewed state when rescoring" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + reviewed_at = ~U[2026-08-24 12:00:00Z] + Repo.update!(Ecto.Changeset.change(prospect, reviewed_at: reviewed_at)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + assert Repo.reload!(prospect).reviewed_at == reviewed_at + end + test "scores a recently expired trial that still has in-window traffic" do # trial ended 2 days ago -> within the 60-day population window (spec ยง2) user = new_user(trial_expiry_date: Date.add(Date.utc_today(), -2))