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