Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extra/lib/plausible/customer_support/trial_prospect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions extra/lib/plausible/customer_support/trial_prospects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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}])
Expand Down
2 changes: 1 addition & 1 deletion extra/lib/plausible/workers/score_trial_prospects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 141 additions & 10 deletions extra/lib/plausible_web/live/customer_support/trial_prospects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<Layout.layout show_search={false} flash={@flash}>
Expand All @@ -42,26 +60,69 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do
</p>
</div>

<div class="mt-4 flex flex-wrap gap-3">
<div class="inline-flex rounded-md shadow-sm" role="group" aria-label="Trial status">
<.filter_link
active={@trial_status == "active"}
patch={index_path(assigns, trial_status: "active", page: 1)}
>
Active trials
</.filter_link>
<.filter_link
active={@trial_status == "all"}
patch={index_path(assigns, trial_status: "all", page: 1)}
>
Include recently expired
</.filter_link>
</div>
<div class="inline-flex rounded-md shadow-sm" role="group" aria-label="Review status">
<.filter_link
active={@reviewed_status == "unreviewed"}
patch={index_path(assigns, reviewed_status: "unreviewed", page: 1)}
>
Unreviewed only
</.filter_link>
<.filter_link
active={@reviewed_status == "all"}
patch={index_path(assigns, reviewed_status: "all", page: 1)}
>
Include reviewed
</.filter_link>
</div>
</div>

<div class="mt-4">
<.table rows={@prospects}>
<.table rows={@prospects} row_attrs={&row_attrs/1}>
<:thead>
<.th>Team</.th>
<.sort_th
label="MRR potential"
by="mrr"
sort_by={@sort_by}
sort_direction={@sort_direction}
trial_status={@trial_status}
reviewed_status={@reviewed_status}
/>
<.th>Feature tier</.th>
<.th>Forced by</.th>
<.th>Traffic estimate</.th>
<.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>
<.th>Review</.th>
</:thead>
<:tbody :let={p}>
<.td>
Expand Down Expand Up @@ -89,6 +150,25 @@ defmodule PlausibleWeb.Live.CustomerSupport.TrialProspects do
<.td>{StatsView.large_number_format(p.estimated_monthly)}</.td>
<.td>{format_date(trial_start(p.team))}</.td>
<.td>{format_date(p.team.trial_expiry_date)}</.td>
<.td>
<button
type="button"
phx-click="set-reviewed"
phx-value-id={p.id}
phx-value-reviewed={if is_nil(p.reviewed_at), do: "true", else: "false"}
class={[
"whitespace-nowrap rounded-md px-2 py-1 text-xs font-medium",
if(p.reviewed_at,
do:
"bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300",
else:
"bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/50 dark:text-indigo-300"
)
]}
>
{if p.reviewed_at, do: "Reviewed", else: "Mark reviewed"}
</button>
</.td>
</:tbody>
</.table>

Expand All @@ -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
)
)
Expand All @@ -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
Expand All @@ -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}
Expand All @@ -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)}
</.link>
"""
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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions test/plausible_web/live/customer_support/trial_prospects_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading