diff --git a/assets/js/dashboard/stats/visitor-graph.js b/assets/js/dashboard/stats/visitor-graph.js index da230988f..f9c50dd82 100644 --- a/assets/js/dashboard/stats/visitor-graph.js +++ b/assets/js/dashboard/stats/visitor-graph.js @@ -6,21 +6,21 @@ import numberFormatter from '../number-formatter' import { isToday, shiftMonths, formatMonth } from '../date' import * as api from '../api' -function dataSets(graphData, ctx) { +function mainSet(plot, present_index, ctx) { var gradient = ctx.createLinearGradient(0, 0, 0, 300); gradient.addColorStop(0, 'rgba(101,116,205, 0.2)'); gradient.addColorStop(1, 'rgba(101,116,205, 0)'); - if (graphData.present_index) { - var dashedPart = graphData.plot.slice(graphData.present_index - 1); - var dashedPlot = (new Array(graphData.plot.length - dashedPart.length)).concat(dashedPart) - for(var i = graphData.present_index; i < graphData.plot.length; i++) { - graphData.plot[i] = undefined + if (present_index) { + var dashedPart = plot.slice(present_index - 1); + var dashedPlot = (new Array(plot.length - dashedPart.length)).concat(dashedPart) + for(var i = present_index; i < plot.length; i++) { + plot[i] = undefined } return [{ label: 'Visitors', - data: graphData.plot, + data: plot, borderWidth: 3, borderColor: 'rgba(101,116,205)', pointBackgroundColor: 'rgba(101,116,205)', @@ -38,7 +38,7 @@ function dataSets(graphData, ctx) { } else { return [{ label: 'Visitors', - data: graphData.plot, + data: plot, borderWidth: 3, borderColor: 'rgba(101,116,205)', pointBackgroundColor: 'rgba(101,116,205)', @@ -47,6 +47,57 @@ function dataSets(graphData, ctx) { } } +function compareSet(plot, present_index, ctx) { + var gradient = ctx.createLinearGradient(0, 0, 0, 300); + gradient.addColorStop(0, 'rgba(255, 68, 87, .2)'); + gradient.addColorStop(1, 'rgba(255, 68, 87, 0)'); + + if (present_index) { + var dashedPart = plot.slice(present_index - 1); + var dashedPlot = (new Array(plot.length - dashedPart.length)).concat(dashedPart) + for(var i = present_index; i < plot.length; i++) { + plot[i] = undefined + } + + return [{ + label: 'Conversions', + data: plot, + borderWidth: 3, + borderColor: 'rgba(255, 68, 87, 1)', + pointBackgroundColor: 'rgba(255, 68, 87, 1)', + backgroundColor: gradient, + }, + { + label: 'Conversions', + data: dashedPlot, + borderWidth: 3, + borderDash: [5, 10], + borderColor: 'rgba(255, 68, 87, 1)', + pointBackgroundColor: 'rgba(255, 68, 87, 1)', + backgroundColor: gradient, + }] + } else { + return [{ + label: 'Conversions', + data: plot, + borderWidth: 3, + borderColor: 'rgba(255, 68, 87, 1)', + pointBackgroundColor: 'rgba(255, 68, 87, 1)', + backgroundColor: gradient, + }] + } +} + +function dataSets(graphData, ctx) { + const dataSets = mainSet(graphData.plot, graphData.present_index, ctx) + + if (graphData.compare_plot) { + return dataSets.concat(compareSet(graphData.compare_plot, graphData.present_index, ctx)) + } else { + return dataSets + } +} + const MONTHS = [ "January", "February", "March", "April", "May", "June", "July", @@ -87,31 +138,39 @@ class LineGraph extends React.Component { animation: false, legend: {display: false}, responsive: true, - elements: {line: {tension: 0.1}, point: {radius: 0}}, + elements: {line: {tension: 0}, point: {radius: 0}}, onClick: this.onClick.bind(this), tooltips: { mode: 'index', intersect: false, xPadding: 10, yPadding: 10, - titleFontSize: 16, + titleFontSize: 18, footerFontSize: 14, - footerFontColor: '#e6e8ff', + bodyFontSize: 14, backgroundColor: 'rgba(25, 30, 56)', + titleMarginBottom: 8, + bodySpacing: 6, + footerMarginTop: 8, + xPadding: 16, + yPadding: 12, + multiKeyBackground: 'none', callbacks: { title: function(dataPoints) { const data = dataPoints[0] - const formatDate = dateFormatter(graphData) - if (graphData.interval === 'month') { - return data.yLabel.toLocaleString() + ' visitors in ' + formatDate(data.xLabel) - } else if (graphData.interval === 'date') { - return data.yLabel.toLocaleString() + ' visitors on ' + formatDate(data.xLabel) - } else if (graphData.interval === 'hour') { - return data.yLabel.toLocaleString() + ' visitors at ' + formatDate(data.xLabel) + return dateFormatter(graphData)(data.xLabel) + }, + beforeBody: function() { + this.drawnLabels = {} + }, + label: function(item) { + const dataset = this._data.datasets[item.datasetIndex] + if (!this.drawnLabels[dataset.label]) { + this.drawnLabels[dataset.label] = true + return ` ${item.yLabel} ${dataset.label}` } }, - label: function() {}, - afterBody: function(dataPoints) { + footer: function(dataPoints) { if (graphData.interval === 'month') { return 'Click to view month' } else if (graphData.interval === 'date') { @@ -179,9 +238,9 @@ class LineGraph extends React.Component { } else if (query.period === '30d') { return 'last month' } else if (query.period === '3mo') { - return 'previous 3 months' + return 'prev 3 months' } else if (query.period === '6mo') { - return 'previous 6 months' + return 'prev 6 months' } } @@ -197,27 +256,30 @@ class LineGraph extends React.Component { } } - render() { + renderTopStats() { const {graphData} = this.props - const extraClass = graphData.interval === 'hour' ? '' : 'cursor-pointer' + return this.props.graphData.top_stats.map((stat, index) => { + const border = index > 0 ? 'border-l border-grey-light' : '' + + return ( +
+
{stat.name}
+
+ { typeof(stat.count) == 'number' ? numberFormatter(stat.count) : stat.percentage + '%' } +
+ {this.renderComparison(stat.change)} +
+ ) + }) + } + + render() { + const extraClass = this.props.graphData.interval === 'hour' ? '' : 'cursor-pointer' return (
-
-
UNIQUE VISITORS
-
- {numberFormatter(graphData.unique_visitors)} -
- {this.renderComparison(graphData.change_visitors)} -
-
-
TOTAL {eventName(this.props.query)}
-
- {numberFormatter(graphData.pageviews)} -
- {this.renderComparison(graphData.change_pageviews)} -
+ { this.renderTopStats() }
diff --git a/lib/plausible/stats/stats.ex b/lib/plausible/stats/stats.ex index 83b4ba513..2981d6567 100644 --- a/lib/plausible/stats/stats.ex +++ b/lib/plausible/stats/stats.ex @@ -29,37 +29,59 @@ defmodule Plausible.Stats do end) groups = Repo.all( - from e in base_query(site, query), + from e in base_query(site, %{query | filters: %{}}), group_by: 1, order_by: 1, select: {fragment("date_trunc('month', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} ) |> Enum.into(%{}) |> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end) + compare_groups = if query.filters["goal"] do + Repo.all( + from e in base_query(site, query), + group_by: 1, + order_by: 1, + select: {fragment("date_trunc('month', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} + ) |> Enum.into(%{}) + |> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end) + end + present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> Timex.to_date |> Timex.beginning_of_month end) plot = Enum.map(steps, fn step -> groups[step] || 0 end) + compare_plot = compare_groups && Enum.map(steps, fn step -> compare_groups[step] || 0 end) labels = Enum.map(steps, fn step -> Timex.format!(step, "{ISOdate}") end) - {plot, labels, present_index} + {plot, compare_plot, labels, present_index} end def calculate_plot(site, %Query{step_type: "date"} = query) do steps = Enum.into(query.date_range, []) groups = Repo.all( - from e in base_query(site, query), + from e in base_query(site, %{ query | filters: %{} }), group_by: 1, order_by: 1, select: {fragment("date_trunc('day', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} ) |> Enum.into(%{}) |> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end) + compare_groups = if query.filters["goal"] do + Repo.all( + from e in base_query(site, query), + group_by: 1, + order_by: 1, + select: {fragment("date_trunc('day', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} + ) |> Enum.into(%{}) + |> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end) + end + present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> Timex.to_date end) steps_to_show = if present_index, do: present_index + 1, else: Enum.count(steps) plot = Enum.map(steps, fn step -> groups[step] || 0 end) |> Enum.take(steps_to_show) + compare_plot = compare_groups && Enum.map(steps, fn step -> compare_groups[step] || 0 end) labels = Enum.map(steps, fn step -> Timex.format!(step, "{ISOdate}") end) - {plot, labels, present_index} + {plot, compare_plot, labels, present_index} end def calculate_plot(site, %Query{step_type: "hour"} = query) do @@ -73,7 +95,7 @@ defmodule Plausible.Stats do end) groups = Repo.all( - from e in base_query(site, query), + from e in base_query(site, %{query | filters: %{}}), group_by: 1, order_by: 1, select: {fragment("date_trunc('hour', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} @@ -81,11 +103,23 @@ defmodule Plausible.Stats do |> Enum.into(%{}) |> transform_keys(fn dt -> NaiveDateTime.truncate(dt, :second) end) + compare_groups = if query.filters["goal"] do + Repo.all( + from e in base_query(site, query), + group_by: 1, + order_by: 1, + select: {fragment("date_trunc('hour', ? at time zone 'utc' at time zone ?)", e.timestamp, ^site.timezone), count(e.user_id, :distinct)} + ) + |> Enum.into(%{}) + |> transform_keys(fn dt -> NaiveDateTime.truncate(dt, :second) end) + end + present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> truncate_to_hour |> NaiveDateTime.truncate(:second) end) steps_to_show = if present_index, do: present_index + 1, else: Enum.count(steps) plot = Enum.map(steps, fn step -> groups[step] || 0 end) |> Enum.take(steps_to_show) + compare_plot = compare_groups && Enum.map(steps, fn step -> compare_groups[step] || 0 end) labels = Enum.map(steps, fn step -> NaiveDateTime.to_iso8601(step) end) - {plot, labels, present_index} + {plot, compare_plot, labels, present_index} end def pageviews_and_visitors(site, query) do @@ -95,6 +129,13 @@ defmodule Plausible.Stats do )) end + def unique_visitors(site, query) do + Repo.one(from( + e in base_query(site, query), + select: count(e.user_id, :distinct) + )) + end + def top_referrers(site, query, limit \\ 5) do Repo.all(from e in base_query(site, query), select: %{name: e.referrer_source, count: count(e.user_id, :distinct)}, diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 95312470e..bb0fba646 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -2,6 +2,7 @@ defmodule PlausibleWeb.Api.StatsController do use PlausibleWeb, :controller use Plausible.Repo alias Plausible.Stats + alias Plausible.Stats.Query plug :authorize def main_graph(conn, params) do @@ -9,22 +10,56 @@ defmodule PlausibleWeb.Api.StatsController do query = Stats.Query.from(site.timezone, params) plot_task = Task.async(fn -> Stats.calculate_plot(site, query) end) - {pageviews, visitors} = Stats.pageviews_and_visitors(site, query) - {change_pageviews, change_visitors} = Stats.compare_pageviews_and_visitors(site, query, {pageviews, visitors}) - {plot, labels, present_index} = Task.await(plot_task) + top_stats = fetch_top_stats(site, query) + {plot, compare_plot, labels, present_index} = Task.await(plot_task) json(conn, %{ plot: plot, + compare_plot: compare_plot, labels: labels, present_index: present_index, - pageviews: pageviews, - unique_visitors: visitors, - change_pageviews: change_pageviews, - change_visitors: change_visitors, - interval: query.step_type + top_stats: top_stats, + interval: query.step_type, }) end + defp fetch_top_stats(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do + prev_query = Query.shift_back(query) + total_visitors = Stats.unique_visitors(site, %{query | filters: %{}}) + prev_total_visitors = Stats.unique_visitors(site, %{prev_query | filters: %{}}) + converted_visitors = Stats.unique_visitors(site, query) + prev_converted_visitors = Stats.unique_visitors(site, prev_query) + conversion_rate = if total_visitors > 0, do: Float.round(converted_visitors / total_visitors * 100, 1), else: 0.0 + prev_conversion_rate = if prev_total_visitors > 0, do: Float.round(prev_converted_visitors / prev_total_visitors * 100, 1), else: 0.0 + + [ + %{name: "Total visitors", count: total_visitors, change: percent_change(prev_total_visitors, total_visitors)}, + %{name: "Converted visitors", count: converted_visitors, change: percent_change(prev_converted_visitors, converted_visitors)}, + %{name: "Conversion rate", percentage: conversion_rate, change: percent_change(prev_conversion_rate, conversion_rate)}, + ] + end + + defp fetch_top_stats(site, query) do + {pageviews, visitors} = Stats.pageviews_and_visitors(site, query) + {prev_pageviews, prev_visitors} = Stats.pageviews_and_visitors(site, Query.shift_back(query)) + + [ + %{name: "Unique visitors", count: visitors, change: percent_change(prev_visitors, visitors)}, + %{name: "Total pageviews", count: pageviews, change: percent_change(prev_pageviews, pageviews)}, + ] + end + + defp percent_change(old_count, new_count) do + cond do + old_count == 0 and new_count > 0 -> + 100 + old_count == 0 and new_count == 0 -> + 0 + true -> + round((new_count - old_count) / old_count * 100) + end + end + def referrers(conn, params) do site = conn.assigns[:site] query = Stats.Query.from(site.timezone, params) diff --git a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs index 815014e2c..c80593b07 100644 --- a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs @@ -23,7 +23,7 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do site = insert(:site, public: true) conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"unique_visitors" => _any} = json_response(conn, 200) + assert %{"plot" => _any} = json_response(conn, 200) end end @@ -48,14 +48,14 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do site = insert(:site, public: true) conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"unique_visitors" => _any} = json_response(conn, 200) + assert %{"plot" => _any} = json_response(conn, 200) end test "returns stats for a private site that the user owns", %{conn: conn, user: user} do site = insert(:site, public: false, members: [user]) conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert %{"unique_visitors" => _any} = json_response(conn, 200) + assert %{"plot" => _any} = json_response(conn, 200) end end end diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs index 0a7693262..de7467f33 100644 --- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs @@ -6,7 +6,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do describe "GET /api/stats/main-graph - plot" do setup [:create_user, :log_in, :create_site] - test "displays pageviews for a day", %{conn: conn, site: site} do + test "displays visitors for a day", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 00:00:00]) insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 23:59:00]) @@ -33,7 +33,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do assert plot == [0, 1] ++ zeroes # Expecting pageview to show at 1am CET end - test "displays pageviews for a month", %{conn: conn, site: site} do + test "displays visitors for a month", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 12:00:00]) insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-31 12:00:00]) @@ -46,7 +46,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do assert List.last(plot) == 1 end - test "displays pageviews for 3 months", %{conn: conn, site: site} do + test "displays visitors for 3 months", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain) insert(:pageview, hostname: site.domain, timestamp: months_ago(2)) @@ -84,7 +84,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do end end - describe "GET /api/stats/main-graph - unique visitors" do + describe "GET /api/stats/main-graph - top stats" do setup [:create_user, :log_in, :create_site] test "counts distinct user ids", %{conn: conn, site: site} do @@ -93,21 +93,19 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") - assert %{"unique_visitors" => 1} = json_response(conn, 200) + res = json_response(conn, 200) + assert %{"name" => "Unique visitors", "count" => 1, "change" => 100} in res["top_stats"] end - test "does not count custom events", %{conn: conn, site: site} do + test "does not count custom events in custom user ids", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 00:00:00]) insert(:event, name: "Custom", hostname: site.domain, timestamp: ~N[2019-01-01 00:00:00]) conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") - assert %{"unique_visitors" => 1} = json_response(conn, 200) + res = json_response(conn, 200) + assert %{"name" => "Unique visitors", "count" => 1, "change" => 100} in res["top_stats"] end - end - - describe "GET /api/stats/main-graph - pageviews" do - setup [:create_user, :log_in, :create_site] test "counts total pageviews even from same user ids", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 00:00:00]) @@ -115,32 +113,8 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") - assert %{"pageviews" => 2} = json_response(conn, 200) - end - - test "does not count custom events", %{conn: conn, site: site} do - insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 00:00:00]) - insert(:event, name: "Custom", hostname: site.domain, timestamp: ~N[2019-01-01 00:00:00]) - - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") - - assert %{"unique_visitors" => 1} = json_response(conn, 200) - end - end - - describe "GET /api/stats/main-graph - comparisons" do - setup [:create_user, :log_in, :create_site] - - test "compares unique users with previous time period", %{conn: conn, site: site} do - insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00]) - insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 02:00:00]) - - insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-02 01:00:00]) - insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-02 02:00:00]) - - conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-02") - - assert %{"change_visitors" => 100} = json_response(conn, 200) + res = json_response(conn, 200) + assert %{"name" => "Total pageviews", "count" => 2, "change" => 100} in res["top_stats"] end test "compares pageviews with previous time period", %{conn: conn, site: site} do @@ -151,7 +125,49 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-02") - assert %{"change_pageviews" => -50} = json_response(conn, 200) + res = json_response(conn, 200) + assert %{"name" => "Total pageviews", "count" => 1, "change" => -50} in res["top_stats"] + end + end + + + describe "GET /api/stats/main-graph - filtered for goal" do + setup [:create_user, :log_in, :create_site] + + test "returns total unique visitors", %{conn: conn, site: site} do + insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 02:00:00]) + insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00]) + insert(:event, name: "Signup", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 02:00:00]) + + filters = Jason.encode!(%{goal: "Signup"}) + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01&filters=#{filters}") + + res = json_response(conn, 200) + assert %{"name" => "Total visitors", "count" => 2, "change" => 100} in res["top_stats"] + end + + test "returns converted visitors", %{conn: conn, site: site} do + insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 02:00:00]) + insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00]) + insert(:event, name: "Signup", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 02:00:00]) + + filters = Jason.encode!(%{goal: "Signup"}) + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=month&date=2019-01-01&filters=#{filters}") + + res = json_response(conn, 200) + assert %{"name" => "Converted visitors", "count" => 1, "change" => 100} in res["top_stats"] + end + + test "returns conversion rate", %{conn: conn, site: site} do + insert(:pageview, hostname: site.domain, timestamp: ~N[2019-01-01 02:00:00]) + insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00]) + insert(:event, name: "Signup", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 02:00:00]) + + filters = Jason.encode!(%{goal: "Signup"}) + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01&filters=#{filters}") + + res = json_response(conn, 200) + assert %{"name" => "Conversion rate", "percentage" => 50.0, "change" => 100} in res["top_stats"] end end