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
86 changes: 84 additions & 2 deletions extra/lib/plausible/customer_support/trial_prospects.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
defmodule Plausible.CustomerSupport.TrialProspects do
@moduledoc """
Pure scoring logic for ranking trial teams by revenue potential. Turns a
team's partial traffic sample and premium-feature usage into an estimated MRR.
Scoring logic for ranking trial teams by revenue potential. Turns a team's
partial traffic sample and premium-feature usage into an estimated MRR, and
serves the ranked listing back to the customer support UI.
"""

import Ecto.Query

alias Plausible.CustomerSupport.TrialProspect
alias Plausible.Repo

# Static reference pricing (EUR/mo) + pageview ladder. Hand-maintained; keep in sync when public pricing changes.
@pricing_path Application.app_dir(:plausible, ["priv", "trial_prospect_pricing.json"])
@external_resource @pricing_path
Expand Down Expand Up @@ -37,6 +43,82 @@ defmodule Plausible.CustomerSupport.TrialProspects do

@kind_rank %{starter: 0, growth: 1, business: 2}

@page_size 100
@sortable_columns ~w(mrr trial_start)
@max_expired_days 30

@spec sortable_columns() :: [String.t()]
def sortable_columns, do: @sortable_columns

@doc """
Teams worth scoring and showing: on a trial (or expired within the last
#{@max_expired_days} days) and not yet subscribed.
"""
@spec population_query() :: Ecto.Query.t()
def population_query do
cutoff = Date.add(Date.utc_today(), -@max_expired_days)

from(t in Plausible.Teams.Team,
as: :team,
left_join: s in assoc(t, :subscription),
where: not is_nil(t.trial_expiry_date),
where: is_nil(s.id),
where: t.trial_expiry_date >= ^cutoff
)
end

@spec list(String.t(), :asc | :desc, pos_integer()) :: %{
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()

total_entries = Repo.aggregate(base, :count)
total_pages = max(1, ceil(total_entries / @page_size))
page_number = min(page, total_pages)

prospects =
base
|> preload_team()
|> order_prospects(sort_by, sort_direction)
|> limit(^@page_size)
|> offset(^((page_number - 1) * @page_size))
|> Repo.all()

%{
prospects: prospects,
page_number: page_number,
total_pages: total_pages,
total_entries: total_entries
}
end

defp listing_query do
from(p in TrialProspect,
join: t in subquery(population_query()),
as: :team,
on: t.id == p.team_id
)
end

defp preload_team(q) do
owners = from(u in Plausible.Auth.User, select: struct(u, [:id, :email]))

from([team: t] in q, preload: [team: {t, owners: ^owners}])
end

defp order_prospects(q, "trial_start", direction) do
order_by(q, [team: t], [{^direction, t.inserted_at}])
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}])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
order_by(q, [p], [{^direction, p.over_top_tier}, {^direction, p.estimated_mrr}])
order_by(q, [p], [{^direction, p.estimated_mrr}])

Because PG ranks null value higher than integer, this simpler order by should give us the enterprise candidates top or bottom depending on sort direction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we actually want them on top always, we need order_by(q, [p], [{:desc, p.over_top_tier}, {^direction, p.estimated_mrr}]) according to my testing.

end

@doc """
Combines feature usage, site/member counts + a monthly estimate into the
persisted scoring fields: `kind`, `forced_by`, `pageview_limit`,
Expand Down
18 changes: 2 additions & 16 deletions extra/lib/plausible/workers/score_trial_prospects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ defmodule Plausible.Workers.ScoreTrialProspects do
alias Plausible.Teams
alias Plausible.CustomerSupport.{TrialProspect, TrialProspects}

@max_expired_days 30

@impl Oban.Worker
def perform(_job) do
Date.utc_today()
|> trial_population()
TrialProspects.population_query()
|> Repo.all()
|> Enum.each(&score_and_persist/1)

:ok
Expand All @@ -39,18 +37,6 @@ defmodule Plausible.Workers.ScoreTrialProspects do
end
end

defp trial_population(today) do
cutoff = Date.add(today, -@max_expired_days)

Repo.all(
from t in Teams.Team,
left_join: s in assoc(t, :subscription),
where: not is_nil(t.trial_expiry_date),
where: is_nil(s.id),
where: t.trial_expiry_date >= ^cutoff
)
end

defp score_team(team) do
site_ids = Teams.owned_sites_ids(team)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
💬 Customer Support
</.link>
</h2>
<.styled_link
class="text-sm flex-shrink-0"
patch={Routes.customer_support_trial_prospects_path(PlausibleWeb.Endpoint, :index)}
>
🔥 Trial prospects
</.styled_link>
</div>
"""
end
Expand Down
23 changes: 22 additions & 1 deletion extra/lib/plausible_web/live/customer_support/live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule PlausibleWeb.CustomerSupport.Live do

Provides:
- Standard mount/3 and handle_info/2 implementations
- Tab navigation components and routing utilities
- Tab navigation components and routing utilities
- Common aliases and imports for Customer Support LiveViews
- Convenience API for flashes and redirects
"""
Expand Down Expand Up @@ -92,6 +92,27 @@ defmodule PlausibleWeb.CustomerSupport.Live do
"""
end

attr :active, :boolean, required: true
attr :direction, :atom, required: true

def sort_arrow(%{active: false} = assigns) do
~H"""
<span class="opacity-30">↕</span>
"""
end

def sort_arrow(%{direction: :asc} = assigns) do
~H"""
<span>↑</span>
"""
end

def sort_arrow(assigns) do
~H"""
<span>↓</span>
"""
end

attr :tab, :string, required: true
attr :extra_classes, :string, default: ""
slot :tabs, required: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
use PlausibleWeb, :live_component

import Ecto.Query, except: [update: 2, update: 3]
import PlausibleWeb.CustomerSupport.Live, only: [sort_arrow: 1]
import PlausibleWeb.Live.Components.Pagination

alias Plausible.Repo
Expand Down Expand Up @@ -221,25 +222,4 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do

defp flip_direction(:asc), do: :desc
defp flip_direction(:desc), do: :asc

attr :active, :boolean, required: true
attr :direction, :atom, required: true

defp sort_arrow(%{active: false} = assigns) do
~H"""
<span class="opacity-30"></span>
"""
end

defp sort_arrow(%{direction: :asc} = assigns) do
~H"""
<span></span>
"""
end

defp sort_arrow(assigns) do
~H"""
<span></span>
"""
end
end
Loading
Loading