Merge pull request #16 from plausible-insights/goals

Add goals and conversions
This commit is contained in:
Uku Taht 2019-10-31 14:45:38 +08:00 committed by GitHub
commit f36b7afbb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 494 additions and 11 deletions

View File

@ -31,3 +31,22 @@ if (flash) {
flash.style.display = 'none'
}, 2500)
}
const registerForm = document.getElementById('register-form')
if (registerForm) {
registerForm.addEventListener('submit', function(e) {
e.preventDefault();
setTimeout(submitForm, 1000);
var formSubmitted = false;
function submitForm() {
if (!formSubmitted) {
formSubmitted = true;
registerForm.submit();
}
}
plausible('trigger', 'Signup', {callback: submitForm});
})
}

View File

@ -33,7 +33,7 @@
}
function ignore(reason) {
console.warn('[Plausible] Ignoring pageview because ' + reason);
console.warn('[Plausible] Ignoring event because ' + reason);
}
function getUrl() {
@ -77,7 +77,7 @@
}))
}
function trigger(eventName) {
function trigger(eventName, options) {
if (/localhost$/.test(window.location.hostname)) return ignore('website is running locally');
if (window.location.protocol === 'file:') return ignore('website is running locally');
if (window.document.visibilityState === 'prerender') return ignore('document is prerendering');
@ -95,13 +95,14 @@
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
setUserData(payload)
options && options.callback && options.callback()
}
}
}
function page() {
trigger('pageview')
function page(options) {
trigger('pageview', options)
}
function trackPushState() {

View File

@ -33,7 +33,7 @@
}
function ignore(reason) {
console.warn('[Plausible] Ignoring pageview because ' + reason);
console.warn('[Plausible] Ignoring event because ' + reason);
}
function getUrl() {

View File

@ -124,6 +124,15 @@ if (domainEl) {
document.getElementById('browsers-stats').innerHTML = res
router.updateLinkHandlers()
})
if (document.getElementById('conversion-stats')) {
fetch(`/stats/${domain}/conversions${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('conversion-stats').innerHTML = res
router.updateLinkHandlers()
})
}
})
setInterval(function() {

View File

@ -0,0 +1,34 @@
defmodule Plausible.Goal do
use Ecto.Schema
import Ecto.Changeset
schema "goals" do
field :domain, :string
field :event_name, :string
field :page_path, :string
timestamps()
end
def changeset(goal, attrs \\ %{}) do
goal
|> cast(attrs, [:domain, :event_name, :page_path])
|> validate_required([:domain])
|> validate_event_name_and_page_path()
end
defp validate_event_name_and_page_path(changeset) do
if present?(changeset, :event_name) || present?(changeset, :page_path) do
changeset
else
changeset
|> add_error(:event_name, "this field is required")
|> add_error(:page_path, "this field is required")
end
end
defp present?(changeset, field) do
value = get_field(changeset, field)
value && value != ""
end
end

21
lib/plausible/goals.ex Normal file
View File

@ -0,0 +1,21 @@
defmodule Plausible.Goals do
use Plausible.Repo
alias Plausible.Goal
def create(site, params) do
params = Map.merge(params, %{"domain" => site.domain})
Goal.changeset(%Goal{}, params) |> Repo.insert
end
def for_site(domain) do
Repo.all(
from g in Goal,
where: g.domain == ^domain
)
end
def delete(id) do
Repo.one(from g in Goal, where: g.id == ^id) |> Repo.delete!
end
end

View File

@ -18,6 +18,13 @@ defmodule Plausible.Sites do
)
end
def has_goals?(site) do
Repo.exists?(
from g in Plausible.Goal,
where: g.domain == ^site.domain
)
end
def is_owner?(user_id, site) do
Repo.exists?(
from sm in Plausible.Site.Membership,

View File

@ -181,6 +181,66 @@ defmodule Plausible.Stats do
)
end
def goal_conversions(site, query, _limit \\ 5) do
goals = Repo.all(from g in Plausible.Goal, where: g.domain == ^site.domain)
fetch_pageview_goals(goals, site, query)
++ fetch_event_goals(goals, site, query)
|> sort_conversions()
end
defp fetch_event_goals(goals, site, query) do
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
first_datetime = Timex.to_datetime(first, site.timezone)
{:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
last_datetime = Timex.to_datetime(last, site.timezone)
events = Enum.map(goals, fn goal -> goal.event_name end)
|> Enum.filter(&(&1))
if Enum.count(events) > 0 do
Repo.all(
from e in Plausible.Event,
where: e.hostname == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
where: e.name in ^events,
group_by: e.name,
select: {e.name, count(e.user_id, :distinct)}
)
else
[]
end
end
defp fetch_pageview_goals(goals, site, query) do
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
first_datetime = Timex.to_datetime(first, site.timezone)
{:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
last_datetime = Timex.to_datetime(last, site.timezone)
pages = Enum.map(goals, fn goal -> goal.page_path end)
|> Enum.filter(&(&1))
if Enum.count(pages) > 0 do
Repo.all(
from e in Plausible.Event,
where: e.hostname == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
where: e.name == "pageview",
where: e.pathname in ^pages,
group_by: e.pathname,
select: {fragment("concat('Visit ', ?)", e.pathname), count(e.user_id, :distinct)}
)
else
[]
end
end
defp sort_conversions(conversions) do
Enum.sort_by(conversions, fn {_, count} -> -count end)
end
defp base_query(site, query) do
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
first_datetime = Timex.to_datetime(first, site.timezone)

View File

@ -1,7 +1,7 @@
defmodule PlausibleWeb.SiteController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Sites
alias Plausible.{Sites, Goals}
plug PlausibleWeb.RequireAccountPlug
@ -33,6 +33,59 @@ defmodule PlausibleWeb.SiteController do
|> render("snippet.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def goals(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
goals = Goals.for_site(site.domain)
conn
|> assign(:skip_plausible_tracking, true)
|> render("goal_settings.html",
site: site,
goals: goals,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def new_goal(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
changeset = Plausible.Goal.changeset(%Plausible.Goal{})
conn
|> assign(:skip_plausible_tracking, true)
|> render("new_goal.html",
site: site,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def create_goal(conn, %{"website" => website, "goal" => goal}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
case Plausible.Goals.create(site, goal) do
{:ok, _} ->
conn
|> put_flash(:success, "Goal created succesfully")
|> redirect(to: "/#{website}/goals")
{:error, :goal, changeset, _} ->
conn
|> assign(:skip_plausible_tracking, true)
|> render("new_goal.html",
site: site,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end
def delete_goal(conn, %{"website" => website, "id" => goal_id}) do
Plausible.Goals.delete(goal_id)
conn
|> put_flash(:success, "Goal deleted succesfully")
|> redirect(to: "/#{website}/goals")
end
def settings(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:google_auth)

View File

@ -25,12 +25,14 @@ defmodule PlausibleWeb.StatsController do
{conn, params} = fetch_period(conn, site)
query = Stats.Query.from(site.timezone, params)
current_visitors = Stats.current_visitors(site)
has_goals = user && Plausible.Sites.has_goals?(site)
conn
|> assign(:skip_plausible_tracking, !demo)
|> put_session(site.domain <> "_offer_email_report", nil)
|> render("stats.html",
site: site,
has_goals: has_goals,
query: query,
current_visitors: current_visitors,
title: "Plausible · " <> site.domain,
@ -310,6 +312,20 @@ defmodule PlausibleWeb.StatsController do
end
end
def conversions_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
{conn, params} = fetch_period(conn, site)
query = Stats.Query.from(site.timezone, params)
goals = Stats.goal_conversions(site, query)
render(conn, "conversions_preview.html", layout: false, query: query, site: site, goals: goals)
else
render_error(conn, 404)
end
end
defp current_user_can_access?(_conn, %Plausible.Site{public: true}) do
true
end

View File

@ -93,6 +93,10 @@ defmodule PlausibleWeb.Router do
put "/sites/:website/monthly-report", SiteController, :update_monthly_settings
get "/:website/snippet", SiteController, :add_snippet
get "/:website/settings", SiteController, :settings
get "/:website/goals", SiteController, :goals
get "/:website/goals/new", SiteController, :new_goal
post "/:website/goals", SiteController, :create_goal
delete "/:website/goals/:id", SiteController, :delete_goal
put "/:website/settings", SiteController, :update_settings
put "/:website/settings/google", SiteController, :update_google_auth
delete "/:website", SiteController, :delete_site
@ -103,6 +107,7 @@ defmodule PlausibleWeb.Router do
get "/stats/:domain/screen-sizes", StatsController, :screen_sizes_preview
get "/stats/:domain/operating-systems", StatsController, :operating_systems_preview
get "/stats/:domain/browsers", StatsController, :browsers_preview
get "/stats/:domain/conversions", StatsController, :conversions_preview
get "/stats/:domain/main-graph", StatsController, :main_graph
get "/:website/*path", StatsController, :stats
end

View File

@ -1,4 +1,4 @@
<%= form_for @changeset, "/register", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8"], fn f -> %>
<%= form_for @changeset, "/register", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8", id: "register-form"], fn f -> %>
<div class="flex items-center justify-between">
<h2>Enter your details to get started</h2>
</div>

View File

@ -23,5 +23,7 @@
<p class="text-center text-grey text-xs mt-4">
©2019 Plausible Insights. All rights reserved.
</p>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>

View File

@ -0,0 +1,23 @@
<%= if get_flash(@conn, :success) do %>
<div id="flash" class="max-w-sm w-full rounded mx-auto text-center bg-green-dark text-green-lightest text-sm font-bold px-4 w-full transition overflow-hidden" role="alert">
<p class="py-3"><%= get_flash(@conn, :success) %></p>
</div>
<% end %>
<div class="max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
<h2>Goals for <%= @site.domain %></h2>
<div class="my-6">
<%= if Enum.count(@goals) > 0 do %>
<%= for goal <- @goals do %>
<div class="border-b border-grey-light py-3 flex justify-between">
<small class="font-bold"><%= goal_name(goal) %></small>
<%= button("", to: "/#{@site.domain}/goals/#{goal.id}", method: :delete, class: "text-sm", data: [confirm: "Are you sure you want to remove goal #{goal_name(goal)}? This will just affect the UI, all of your analytics data will stay intact."]) %>
</div>
<% end %>
<% else %>
<div>No goals configured for this site yet</div>
<% end %>
</div>
<%= link("+ Add goal", to: "/#{@site.domain}/goals/new", class: "button mt-4 w-full") %>
</div>

View File

@ -0,0 +1,37 @@
<%= form_for @changeset, "/#{@site.domain}/goals", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2>Add goal for <%= @site.domain %></h2>
<div class="mt-6 text-sm font-bold">Goal trigger</div>
<div class="my-3 w-full flex rounded border border-grey-light">
<div class="w-1/2 text-center py-2 border-r border-grey shadow-inner font-bold bg-grey-lighter cursor-pointer" id="pageview-tab">Pageview</div>
<div class="w-1/2 text-center py-2 bg-grey-lightest cursor-pointer" id="event-tab">Custom event</div>
</div>
<div class="my-6">
<div id="pageview-fields">
<%= label f, :page_path, class: "block text-sm font-bold" %>
<%= text_input f, :page_path, class: "transition mt-3 bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "/success" %>
<%= error_tag f, :page_path %>
</div>
<div id="event-fields" class="hidden">
<%= label f, :event_name, class: "block text-sm font-bold" %>
<%= text_input f, :event_name, class: "transition mt-3 bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "Signup" %>
<%= error_tag f, :event_name %>
</div>
</div>
<%= submit "Add goal →", class: "button mt-4 w-full" %>
<% end %>
<script>
document.getElementById('pageview-tab').onclick = function() {
document.getElementById('pageview-fields').classList.remove('hidden')
document.getElementById('pageview-tab').classList.add('shadow-inner', 'font-bold')
document.getElementById('event-fields').classList.add('hidden')
document.getElementById('event-tab').classList.remove('shadow-inner', 'font-bold')
}
document.getElementById('event-tab').onclick = function() {
document.getElementById('event-fields').classList.remove('hidden')
document.getElementById('event-tab').classList.add('shadow-inner', 'font-bold')
document.getElementById('pageview-fields').classList.add('hidden')
document.getElementById('pageview-tab').classList.remove('shadow-inner', 'font-bold')
}
</script>

View File

@ -0,0 +1,27 @@
<div class="text-center">
<h2>Conversions</h2>
<div class="text-grey-darker mt-1">by unique visitors</div>
</div>
<div class="mt-8">
<%= if Enum.count(@goals) > 0 do %>
<%= for {key, count} <- @goals do %>
<div class="flex items-center justify-between my-2">
<span class="truncate" style="max-width: 80%;"><%= key %></span>
<span title="<%= count %>"><%= large_number_format(count) %></span>
</div>
<%= bar(count, @goals, :indigo) %>
<% end %>
<% else %>
<div class="text-center">No conversions for this period</div>
<div class="text-center mt-2"><%= link("Manage goals", to: "/#{@site.domain}/goals", class: "text-indigo") %></div>
<% end %>
</div>
<%= if Enum.count(@goals) >= 5 do %>
<div class="text-center">
<a href="/<%= @site.domain %>/conversions<%= query_params(@query) %>" data-pushstate class="font-bold text-sm text-grey-dark hover:text-red transition tracking-wide">
<svg style="fill: #8795a1;" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-maximize"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
MORE
</a>
</div>
<% end %>

View File

@ -3,8 +3,7 @@
<div class="text-center bg-blue-lighter text-blue-darkest text-sm font-bold px-4 w-full rounded transition" style="top: 91px" role="alert">
<%= link("Click here to enable weekly email reports →", to: "/#{@site.domain}/settings#email-reports", class: "py-2 block") %>
</div>
<% end %>
<div class="pt-12"></div>
<% end %> <div class="pt-12"></div>
<div class="w-full sm:flex justify-between items-center">
<div class="w-full flex items-center">
<h2 class="text-left mr-8">Analytics for <%= link(@site.domain, to: "//" <> @site.domain, target: "_blank") %></h2>
@ -91,14 +90,22 @@
</div>
<div class="w-full block md:flex items-start justify-between mt-6">
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="screen-sizes-stats">
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="browsers-stats">
<div class="loading my-32 mx-auto"><div></div></div>
</div>
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="operating-systems-stats">
<div class="loading my-32 mx-auto"><div></div></div>
</div>
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="browsers-stats">
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="screen-sizes-stats">
<div class="loading my-32 mx-auto"><div></div></div>
</div>
</div>
<%= if @has_goals do %>
<div class="w-full block md:flex items-start justify-between mt-6">
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="conversion-stats">
<div class="loading my-32 mx-auto"><div></div></div>
</div>
</div>
<% end %>
</div>

View File

@ -1,6 +1,14 @@
defmodule PlausibleWeb.SiteView do
use PlausibleWeb, :view
def goal_name(%Plausible.Goal{page_path: page_path}) when is_binary(page_path) do
"Visit " <> page_path
end
def goal_name(%Plausible.Goal{event_name: name}) when is_binary(name) do
name
end
def snippet() do
"""
<script>

View File

@ -0,0 +1,16 @@
defmodule Plausible.Repo.Migrations.AddGoals do
use Ecto.Migration
def change do
create table(:goals) do
add :domain, :text, null: false
add :name, :text, null: false
add :event_name, :text
add :page_path, :text
timestamps()
end
create unique_index(:goals, [:domain, :name])
end
end

View File

@ -0,0 +1,9 @@
defmodule Plausible.Repo.Migrations.RemoveGoalName do
use Ecto.Migration
def change do
alter table(:goals) do
remove :name
end
end
end

View File

@ -319,6 +319,61 @@ defmodule Plausible.StatsTest do
end
end
describe "goal_conversions" do
test "shows custom event conversions" do
site = insert(:site)
insert(:goal, %{domain: site.domain, event_name: "Register"})
insert(:goal, %{domain: site.domain, event_name: "Newsletter signup"})
insert(:event, name: "Register", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "Register", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "Newsletter signup", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "Irrelevant", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
conversions = Stats.goal_conversions(site, query)
assert conversions == [
{"Register", 2},
{"Newsletter signup", 1}
]
end
test "shows pageview conversions" do
site = insert(:site)
insert(:goal, %{domain: site.domain, page_path: "/success"})
insert(:goal, %{domain: site.domain, page_path: "/register"})
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/register", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/irrelevant", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
conversions = Stats.goal_conversions(site, query)
assert conversions == [
{"Visit /success", 2},
{"Visit /register", 1}
]
end
test "shows mixed conversions in order of occurence" do
site = insert(:site)
insert(:goal, %{domain: site.domain, page_path: "/success"})
insert(:goal, %{domain: site.domain, event_name: "Signup"})
insert(:event, name: "Signup", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
conversions = Stats.goal_conversions(site, query)
assert conversions == [
{"Visit /success", 2},
{"Signup", 1}
]
end
end
defp months_ago(months) do
Timex.now() |> Timex.shift(months: -months)
end

View File

@ -123,4 +123,74 @@ defmodule PlausibleWeb.SiteControllerTest do
refute Repo.exists?(from e in Plausible.Event, where: e.id == ^pageview.id)
end
end
describe "GET /:website/goals/new" do
setup [:create_user, :log_in, :create_site]
test "shows form to create a new goal", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/goals/new")
assert html_response(conn, 200) =~ "Add goal"
end
end
describe "POST /:website/goals" do
setup [:create_user, :log_in, :create_site]
test "creates a pageview goal for the website", %{conn: conn, site: site} do
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "/success",
event_name: ""
}
})
goal = Repo.one(Plausible.Goal)
assert goal.page_path == "/success"
assert goal.event_name == nil
end
test "creates a custom event goal for the website", %{conn: conn, site: site} do
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Signup"
}
})
goal = Repo.one(Plausible.Goal)
assert goal.event_name == "Signup"
assert goal.page_path == nil
end
end
describe "GET /:website/goals" do
setup [:create_user, :log_in, :create_site]
test "lists goals for the site", %{conn: conn, site: site} do
insert(:goal, domain: site.domain, event_name: "Custom event")
insert(:goal, domain: site.domain, page_path: "/register")
conn = get(conn, "/#{site.domain}/goals")
assert html_response(conn, 200) =~ "Goals for " <> site.domain
assert html_response(conn, 200) =~ "Custom event"
assert html_response(conn, 200) =~ "Visit /register"
end
end
describe "DELETE /:website/goals/:id" do
setup [:create_user, :log_in, :create_site]
test "lists goals for the site", %{conn: conn, site: site} do
goal = insert(:goal, domain: site.domain, event_name: "Custom event")
delete(conn, "/#{site.domain}/goals/#{goal.id}")
assert Repo.aggregate(Plausible.Goal, :count, :id) == 0
end
end
end

View File

@ -42,6 +42,10 @@ defmodule Plausible.Factory do
}
end
def goal_factory do
%Plausible.Goal{}
end
def subscription_factory do
%Plausible.Billing.Subscription{
paddle_subscription_id: sequence(:paddle_subscription_id, &"subscription-#{&1}"),