diff --git a/assets/css/app.css b/assets/css/app.css index 3077eab3c..5467a00e4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,12 +64,12 @@ blockquote { @tailwind utilities; .main-graph { - height: 310px; + height: 440px; } @screen md { .main-graph { - height: 360px; + height: 480px; } } diff --git a/assets/js/dashboard/stats/visitor-graph.js b/assets/js/dashboard/stats/visitor-graph.js index f9c50dd82..a18578a5c 100644 --- a/assets/js/dashboard/stats/visitor-graph.js +++ b/assets/js/dashboard/stats/visitor-graph.js @@ -123,6 +123,16 @@ function dateFormatter(graphData) { } } +function formatStat(stat) { + if (typeof(stat.count) === 'number') { + return numberFormatter(stat.count) + } else if (typeof(stat.duration) === 'number') { + return new Date(stat.duration * 1000).toISOString().substr(14, 5) + } else if (typeof(stat.percentage) === 'number') { + return stat.percentage + '%' + } +} + class LineGraph extends React.Component { componentDidMount() { const {graphData} = this.props @@ -244,13 +254,15 @@ class LineGraph extends React.Component { } } - renderComparison(comparison) { + renderComparison(name, comparison) { const formattedComparison = numberFormatter(Math.abs(comparison)) if (comparison > 0) { - return {formattedComparison}% from {this.comparisonTimeframe()} + const color = name === 'Bounce rate' ? 'text-red-light' : 'text-green-dark' + return {formattedComparison}% from {this.comparisonTimeframe()} } else if (comparison < 0) { - return {formattedComparison}% from {this.comparisonTimeframe()} + const color = name === 'Bounce rate' ? 'text-green-dark' : 'text-red-light' + return {formattedComparison}% from {this.comparisonTimeframe()} } else if (comparison === 0) { return 〰 same as {this.comparisonTimeframe()} } @@ -259,15 +271,16 @@ class LineGraph extends React.Component { renderTopStats() { const {graphData} = this.props return this.props.graphData.top_stats.map((stat, index) => { - const border = index > 0 ? 'border-l border-grey-light' : '' + let border = index > 0 ? 'lg:border-l border-grey-light' : '' + border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border return ( -
+
{stat.name}
- { typeof(stat.count) == 'number' ? numberFormatter(stat.count) : stat.percentage + '%' } + {formatStat(stat)}
- {this.renderComparison(stat.change)} + {this.renderComparison(stat.name, stat.change)}
) }) @@ -278,7 +291,7 @@ class LineGraph extends React.Component { return ( -
+
{ this.renderTopStats() }
diff --git a/lib/plausible/stats/stats.ex b/lib/plausible/stats/stats.ex index 2981d6567..897e7d3a7 100644 --- a/lib/plausible/stats/stats.ex +++ b/lib/plausible/stats/stats.ex @@ -122,6 +122,33 @@ defmodule Plausible.Stats do {plot, compare_plot, labels, present_index} end + def bounce_rate(site, query) do + {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + + sessions_query = from(s in Plausible.Session, + where: s.hostname == ^site.domain, + where: s.new_visitor, + where: s.start >= ^first_datetime and s.start < ^last_datetime + ) + total_sessions = Repo.one( from s in sessions_query, select: count(s)) + bounced_sessions = Repo.one(from s in sessions_query, where: s.is_bounce, select: count(s)) + + case total_sessions do + 0 -> 0 + total -> round(bounced_sessions / total * 100) + end + end + + def session_length(site, query) do + {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) + + Repo.one(from s in Plausible.Session, + where: s.hostname == ^site.domain, + where: s.start >= ^first_datetime and s.start < ^last_datetime, + select: coalesce(avg(s.length), 0) + ) |> Decimal.round |> Decimal.to_integer + end + def pageviews_and_visitors(site, query) do Repo.one(from( e in base_query(site, query), @@ -296,14 +323,7 @@ defmodule Plausible.Stats do end defp base_query(site, query, events \\ ["pageview"]) do - {:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00]) - first_datetime = Timex.to_datetime(first, site.timezone) - |> Timex.Timezone.convert("UTC") - - {:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00]) - last_datetime = Timex.to_datetime(last, site.timezone) - |> Timex.Timezone.convert("UTC") - + {first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone) {goal_event, path} = event_name_for_goal(query) q = from(e in Plausible.Event, @@ -324,6 +344,18 @@ defmodule Plausible.Stats do end end + defp date_range_utc_boundaries(date_range, timezone) do + {:ok, first} = NaiveDateTime.new(date_range.first, ~T[00:00:00]) + first_datetime = Timex.to_datetime(first, timezone) + |> Timex.Timezone.convert("UTC") + + {:ok, last} = NaiveDateTime.new(date_range.last |> Timex.shift(days: 1), ~T[00:00:00]) + last_datetime = Timex.to_datetime(last, timezone) + |> Timex.Timezone.convert("UTC") + + {first_datetime, last_datetime} + end + defp event_name_for_goal(query) do case query.filters["goal"] do "Visit " <> page -> diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index bb0fba646..e984da9f2 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -40,12 +40,21 @@ defmodule PlausibleWeb.Api.StatsController do end defp fetch_top_stats(site, query) do + prev_query = Query.shift_back(query) {pageviews, visitors} = Stats.pageviews_and_visitors(site, query) - {prev_pageviews, prev_visitors} = Stats.pageviews_and_visitors(site, Query.shift_back(query)) + {prev_pageviews, prev_visitors} = Stats.pageviews_and_visitors(site, prev_query) + bounce_rate = Stats.bounce_rate(site, query) + prev_bounce_rate = Stats.bounce_rate(site, prev_query) + change_bounce_rate = if prev_bounce_rate > 0, do: bounce_rate - prev_bounce_rate + session_length = Stats.session_length(site, query) + prev_session_length = Stats.session_length(site, prev_query) + change_session_length = if prev_session_length > 0, do: session_length - prev_session_length [ %{name: "Unique visitors", count: visitors, change: percent_change(prev_visitors, visitors)}, %{name: "Total pageviews", count: pageviews, change: percent_change(prev_pageviews, pageviews)}, + %{name: "Bounce rate", percentage: bounce_rate, change: change_bounce_rate}, + %{name: "Session length", duration: session_length, change: change_session_length}, ] 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 de7467f33..546b1ba9a 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 @@ -87,7 +87,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest 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 + test "unique users counts distinct user ids", %{conn: conn, site: site} do insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 00:00:00]) insert(:pageview, hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 23:59:00]) @@ -128,6 +128,52 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do res = json_response(conn, 200) assert %{"name" => "Total pageviews", "count" => 1, "change" => -50} in res["top_stats"] end + + test "calculates bounce rate", %{conn: conn, site: site} do + insert(:session, hostname: site.domain, is_bounce: true, start: ~N[2019-01-01 01:00:00]) + insert(:session, hostname: site.domain, is_bounce: false, start: ~N[2019-01-01 02:00:00]) + + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") + + res = json_response(conn, 200) + assert %{"name" => "Bounce rate", "percentage" => 50, "change" => nil} in res["top_stats"] + end + + test "calculates change in bounce rate", %{conn: conn, site: site} do + insert(:session, hostname: site.domain, is_bounce: true, start: ~N[2019-01-01 01:00:00]) + insert(:session, hostname: site.domain, is_bounce: false, start: ~N[2019-01-01 02:00:00]) + + insert(:session, hostname: site.domain, is_bounce: true, start: ~N[2019-01-02 01:00:00]) + insert(:session, hostname: site.domain, is_bounce: true, start: ~N[2019-01-02 01:00:00]) + insert(:session, hostname: site.domain, is_bounce: false, start: ~N[2019-01-02 02:00:00]) + + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-02") + + res = json_response(conn, 200) + assert %{"name" => "Bounce rate", "percentage" => 67, "change" => 17} in res["top_stats"] + end + + test "calculates avg session length", %{conn: conn, site: site} do + insert(:session, hostname: site.domain, length: 10, start: ~N[2019-01-01 01:00:00]) + insert(:session, hostname: site.domain, length: 20, start: ~N[2019-01-01 02:00:00]) + + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01") + + res = json_response(conn, 200) + assert %{"name" => "Session length", "duration" => 15, "change" => nil} in res["top_stats"] + end + + test "calculates change in session length", %{conn: conn, site: site} do + insert(:session, hostname: site.domain, length: 10, start: ~N[2019-01-01 01:00:00]) + insert(:session, hostname: site.domain, length: 20, start: ~N[2019-01-01 02:00:00]) + + insert(:session, hostname: site.domain, length: 20, start: ~N[2019-01-02 02:00:00]) + + conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-02") + + res = json_response(conn, 200) + assert %{"name" => "Session length", "duration" => 20, "change" => 5} in res["top_stats"] + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index b994345b9..2326e3b48 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -22,6 +22,18 @@ defmodule Plausible.Factory do } end + def session_factory do + hostname = sequence(:domain, &"example-#{&1}.com") + + %Plausible.Session{ + hostname: hostname, + new_visitor: true, + user_id: UUID.uuid4(), + start: Timex.now(), + is_bounce: false + } + end + def pageview_factory do struct!( event_factory(),