Click on goal to see full conversion report (#18)

* Click on goal to see conversions for it

* Add test for goal filter

* Remove unused code in query

* Test query for 6 months

* Test pageview goal filter for referrers

* Test Google referrer drilldown
This commit is contained in:
Uku Taht 2019-11-20 16:59:01 +08:00 committed by GitHub
commit a2a64da7f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 299 additions and 150 deletions

View File

@ -11,7 +11,8 @@ function serialize(obj) {
export function get(url, query, ...extraQuery) {
query = Object.assign({}, query, {
date: query.date ? formatISO(query.date) : undefined
date: query.date ? formatISO(query.date) : undefined,
filters: query.filters ? JSON.stringify(query.filters) : undefined
}, ...extraQuery)
url = url + `?${serialize(query)}`

View File

@ -0,0 +1,23 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
import {removeQueryParam} from './query'
function Filters({query, history, location}) {
if (query.filters.goal) {
function removeGoal() {
history.push({search: removeQueryParam(location.search, 'goal')})
}
return (
<div className="mt-4">
<span className="bg-white text-grey-darker shadow text-sm rounded py-2 px-3">
Completed goal <b>{query.filters.goal}</b> <b className="ml-1 cursor-pointer" onClick={removeGoal}></b>
</span>
</div>
)
}
return null
}
export default withRouter(Filters)

View File

@ -2,6 +2,7 @@ import React from 'react';
import { withRouter } from 'react-router-dom'
import Datepicker from './datepicker'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Referrers from './stats/referrers'
@ -40,11 +41,12 @@ class Stats extends React.Component {
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8">Analytics for <a href="//{this.props.domain}" target="_blank">{this.props.site.domain}</a></h2>
<h2 className="text-left mr-8">Analytics for <a href={`//${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
<CurrentVisitors site={this.props.site} />
</div>
<Datepicker site={this.props.site} query={this.state.query} />
</div>
<Filters query={this.state.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.state.query} />
<div className="w-full block md:flex items-start justify-between mt-6">
<Referrers site={this.props.site} query={this.state.query} />

View File

@ -1,5 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import 'url-search-params-polyfill';
import Router from './router'

View File

@ -1,19 +1,10 @@
import {formatDay, formatMonthYYYY, newDateInOffset} from './date'
function parseQueryString(queryString) {
var query = {};
var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].split('=');
query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
}
return query;
}
const PERIODS = ['day', 'month', '7d', '30d', '3mo', '6mo']
export function parseQuery(querystring, site) {
let {period, date} = parseQueryString(querystring)
const q = new URLSearchParams(querystring)
let period = q.get('period')
const periodKey = 'period__' + site.domain
if (PERIODS.includes(period)) {
@ -28,7 +19,10 @@ export function parseQuery(querystring, site) {
return {
period: period,
date: date ? new Date(date) : newDateInOffset(site.offset)
date: q.get('date') ? new Date(q.get('date')) : newDateInOffset(site.offset),
filters: {
'goal': q.get('goal')
}
}
}
@ -47,3 +41,20 @@ export function toHuman(query) {
return 'in the last 6 months'
}
}
export function removeQueryParam(search, parameter) {
const q = new URLSearchParams(search)
q.delete(parameter)
return q.toString()
}
export function eventName(query) {
if (query.filters.goal) {
if (query.filters.goal.startsWith('Visit ')) {
return 'pageviews'
}
return 'events'
} else {
return 'pageviews'
}
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import Dash from './index'
import Modal from './stats/modals/modal'
import ReferrersModal from './stats/modals/referrers'
@ -9,16 +9,25 @@ import CountriesModal from './stats/modals/countries'
import BrowsersModal from './stats/modals/browsers'
import OperatingSystemsModal from './stats/modals/operating-systems'
import {
BrowserRouter,
Switch,
Route
} from "react-router-dom";
import {BrowserRouter, Switch, Route, useLocation} from "react-router-dom";
function ScrollToTop() {
const location = useLocation();
useEffect(() => {
if (location.state && location.state.scrollTop) {
window.scrollTo(0, 0);
}
}, [location]);
return null;
}
export default function Router({site}) {
return (
<BrowserRouter>
<Route path="/:domain">
<ScrollToTop />
<Dash site={site} />
<Switch>
<Route exact path="/:domain/referrers">

View File

@ -41,13 +41,13 @@ export default class Browsers extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.browsers) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Browsers</h2>
<div className="text-grey-darker mt-1">by visitors</div>

View File

@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom'
import Bar from './bar'
import MoreLink from './more-link'
@ -28,10 +29,13 @@ export default class Conversions extends React.Component {
}
renderGoal(goal) {
const query = new URLSearchParams(window.location.search)
query.set('goal', goal.name)
return (
<React.Fragment key={goal.name}>
<div className="flex items-center justify-between my-2">
<span className="truncate" style={{maxWidth: '80%'}}>{ goal.name }</span>
<Link to={{search: query.toString(), state: {scrollTop: true}}} className="truncate hover:underline" style={{maxWidth: '80%'}}>{ goal.name }</Link>
<span>{numberFormatter(goal.count)}</span>
</div>
<Bar count={goal.count} all={this.state.goals} color="indigo" />

View File

@ -43,13 +43,13 @@ export default class Countries extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.countries) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Top Countries</h2>
<div className="text-grey-darker mt-1">by visitors</div>

View File

@ -41,7 +41,15 @@ class GoogleKeywordsModal extends React.Component {
}
renderKeywords() {
if (this.state.notConfigured) {
if (this.state.query.filters.goal) {
return (
<div className="text-center text-grey-darker mt-6">
<RocketIcon />
<div className="text-lg">Sorry, we cannot show which keywords converted best for goal <b>{this.state.query.filters.goal}</b></div>
<div className="text-lg">Google has a monopoly on that data which helps them dominate the analytics market</div>
</div>
)
} else if (this.state.notConfigured) {
if (this.state.isOwner) {
return (
<div className="text-center text-grey-darker mt-6">

View File

@ -33,6 +33,14 @@ class ReferrerDrilldownModal extends React.Component {
)
}
renderGoalText() {
if (this.state.query.filters.goal) {
return (
<h1 className="text-grey-darker leading-none">completed {this.state.query.filters.goal}</h1>
)
}
}
renderBody() {
if (this.state.loading) {
return (
@ -47,10 +55,10 @@ class ReferrerDrilldownModal extends React.Component {
<div className="my-4 border-b border-grey-light"></div>
<main className="modal__content mt-0">
<h1>{this.state.totalVisitors} new visitors from {this.props.match.params.referrer}</h1>
<h1 className="text-grey-darker" style={{transform: 'translateY(-1rem)'}}>{toHuman(this.state.query)}</h1>
<h1 className="mb-0 leading-none">{this.state.totalVisitors} visitors from {this.props.match.params.referrer}<br /> {toHuman(this.state.query)}</h1>
{this.renderGoalText()}
<div className="mt-4">
<div className="mt-8">
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
</div>
</main>

View File

@ -43,7 +43,7 @@ class ReferrersModal extends React.Component {
<header className="modal__header">
<h1>Referrers</h1>
</header>
<div className="text-grey-darker text-lg ml-1 mt-1">by new visitors</div>
<div className="text-grey-darker text-lg ml-1 mt-1">by visitors</div>
<div className="my-4 border-b border-grey-light"></div>
<main className="modal__content">

View File

@ -41,13 +41,13 @@ export default class OperatingSystems extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.systems) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Operating Systems</h2>
<div className="text-grey-darker mt-1">by visitors</div>

View File

@ -3,6 +3,7 @@ import React from 'react';
import Bar from './bar'
import MoreLink from './more-link'
import numberFormatter from '../number-formatter'
import { eventName } from '../query'
import * as api from '../api'
export default class Pages extends React.Component {
@ -44,16 +45,16 @@ export default class Pages extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.pages) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Top Pages</h2>
<div className="text-grey-darker mt-1">by pageviews</div>
<div className="text-grey-darker mt-1">by {eventName(this.props.query)}</div>
</div>
<div className="mt-8">

View File

@ -43,16 +43,16 @@ export default class Referrers extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.referrers) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Top Referrers</h2>
<div className="text-grey-darker mt-1">by new visitors</div>
<div className="text-grey-darker mt-1">by visitors</div>
</div>
<div className="mt-8">

View File

@ -70,13 +70,13 @@ export default class ScreenSizes extends React.Component {
render() {
if (this.state.loading) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="loading my-32 mx-auto"><div></div></div>
</div>
)
} else if (this.state.sizes) {
return (
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4">
<div className="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" style={{height: '405px'}}>
<div className="text-center">
<h2>Screen Sizes</h2>
<div className="text-grey-darker mt-1">by visitors</div>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
import Chart from 'chart.js'
import { eventName } from '../query'
import numberFormatter from '../number-formatter'
import { isToday, shiftMonths, formatMonth } from '../date'
import * as api from '../api'
@ -205,7 +206,7 @@ class LineGraph extends React.Component {
{this.renderComparison(graphData.change_visitors)}
</div>
<div className="pl-8 w-60">
<div className="text-grey-dark text-xs font-bold tracking-wide">TOTAL PAGEVIEWS</div>
<div className="text-grey-dark text-xs font-bold tracking-wide uppercase">TOTAL {eventName(this.props.query)}</div>
<div className="my-1 flex items-end justify-between">
<b className="text-2xl" title={graphData.pageviews.toLocaleString()}>{numberFormatter(graphData.pageviews)}</b>
</div>

View File

@ -9444,6 +9444,11 @@
}
}
},
"url-search-params-polyfill": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-7.0.0.tgz",
"integrity": "sha512-0SEH3s+wCNbxEE/rWUalN004ICNi23Q74Ksc0gS2kG8EXnbayxGOrV97JdwnIVPKZ75Xk0hvKXvtIC4xReLMgg=="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View File

@ -12,7 +12,8 @@
"phoenix_html": "file:../deps/phoenix_html",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-router-dom": "^5.1.2"
"react-router-dom": "^5.1.2",
"url-search-params-polyfill": "^7.0.0"
},
"devDependencies": {
"@babel/core": "^7.5.5",

View File

@ -42,7 +42,8 @@ config :ref_inspector,
database_path: "priv/ref_inspector"
config :plausible,
paddle_api: Plausible.Billing.PaddleApi
paddle_api: Plausible.Billing.PaddleApi,
google_api: Plausible.Google.Api
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.

View File

@ -24,4 +24,5 @@ config :plausible, Plausible.Mailer,
adapter: Bamboo.TestAdapter
config :plausible,
paddle_api: Plausible.PaddleApi.Mock
paddle_api: Plausible.PaddleApi.Mock,
google_api: Plausible.Google.Api.Mock

View File

@ -1,23 +1,5 @@
defmodule Plausible.Stats.Query do
defstruct [date_range: nil, step_type: nil, period: nil, steps: nil]
def new(attrs) do
attrs
|> Enum.into(%{})
|> Map.put(:__struct__, __MODULE__)
end
def month(date) do
%__MODULE__{
date_range: Date.range(Timex.beginning_of_month(date), Timex.end_of_month(date))
}
end
def day(date) do
%__MODULE__{
date_range: Date.range(date, date)
}
end
defstruct [date_range: nil, step_type: nil, period: nil, steps: nil, filters: %{}]
def shift_back(%__MODULE__{period: "day"} = query) do
new_date = query.date_range.first |> Timex.shift(days: -1)
@ -31,61 +13,66 @@ defmodule Plausible.Stats.Query do
Map.put(query, :date_range, Date.range(new_first, new_last))
end
def from(_tz, %{"period" => "day", "date" => date}) do
def from(_tz, %{"period" => "day", "date" => date} = params) do
date = Date.from_iso8601!(date)
%__MODULE__{
period: "day",
date_range: Date.range(date, date),
step_type: "hour"
step_type: "hour",
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "day"}) do
def from(tz, %{"period" => "day"} = params) do
date = today(tz)
%__MODULE__{
period: "day",
date_range: Date.range(date, date),
step_type: "hour"
step_type: "hour",
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "7d"}) do
def from(tz, %{"period" => "7d"} = params) do
end_date = today(tz)
start_date = end_date |> Timex.shift(days: -7)
%__MODULE__{
period: "7d",
date_range: Date.range(start_date, end_date),
step_type: "date"
step_type: "date",
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "30d"}) do
def from(tz, %{"period" => "30d"} = params) do
end_date = today(tz)
start_date = end_date |> Timex.shift(days: -30)
%__MODULE__{
period: "30d",
date_range: Date.range(start_date, end_date),
step_type: "date"
step_type: "date",
filters: parse_filters(params)
}
end
def from(_tz, %{"period" => "month", "date" => month_start}) do
start_date = Date.from_iso8601!(month_start) |> Timex.beginning_of_month
def from(_tz, %{"period" => "month", "date" => date} = params) do
start_date = Date.from_iso8601!(date) |> Timex.beginning_of_month
end_date = Timex.end_of_month(start_date)
%__MODULE__{
period: "month",
date_range: Date.range(start_date, end_date),
step_type: "date",
steps: Timex.diff(start_date, end_date, :days)
steps: Timex.diff(start_date, end_date, :days),
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "3mo"}) do
def from(tz, %{"period" => "3mo"} = params) do
start_date = Timex.shift(today(tz), months: -2)
|> Timex.beginning_of_month()
@ -93,11 +80,12 @@ defmodule Plausible.Stats.Query do
period: "3mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 3
steps: 3,
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "6mo"}) do
def from(tz, %{"period" => "6mo"} = params) do
start_date = Timex.shift(today(tz), months: -5)
|> Timex.beginning_of_month()
@ -105,28 +93,23 @@ defmodule Plausible.Stats.Query do
period: "6mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 6
}
end
def from(_tz, %{"period" => "custom", "from" => from, "to" => to}) do
start_date = Date.from_iso8601!(from)
end_date = Date.from_iso8601!(to)
date_range = Date.range(start_date, end_date)
%__MODULE__{
period: "custom",
date_range: date_range,
step_type: "date"
steps: 6,
filters: parse_filters(params)
}
end
def from(tz, _) do
__MODULE__.from(tz, %{"period" => "6mo"})
__MODULE__.from(tz, %{"period" => "30d"})
end
defp today(tz) do
Timex.now(tz) |> Timex.to_date
end
defp parse_filters(params) do
if params["filters"] do
Jason.decode!(params["filters"])
end
end
end

View File

@ -96,9 +96,9 @@ defmodule Plausible.Stats do
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.referrer_source)},
select: %{name: e.referrer_source, count: count(e.user_id, :distinct)},
group_by: e.referrer_source,
where: e.new_visitor == true and not is_nil(e.referrer_source),
where: not is_nil(e.referrer_source),
order_by: [desc: 2],
limit: ^limit
)
@ -107,16 +107,16 @@ defmodule Plausible.Stats do
def visitors_from_referrer(site, query, referrer) do
Repo.one(
from e in base_query(site, query),
select: count(e),
where: e.new_visitor == true and e.referrer_source == ^referrer
select: count(e.user_id, :distinct),
where: e.referrer_source == ^referrer
)
end
def referrer_drilldown(site, query, referrer) do
Repo.all(from e in base_query(site, query),
select: %{name: e.referrer, count: count(e)},
select: %{name: e.referrer, count: count(e.user_id, :distinct)},
group_by: e.referrer,
where: e.new_visitor == true and e.referrer_source == ^referrer,
where: e.referrer_source == ^referrer,
order_by: [desc: 2],
limit: 100
)
@ -135,9 +135,9 @@ defmodule Plausible.Stats do
def top_screen_sizes(site, query) do
Repo.all(from e in base_query(site, query),
select: {e.screen_size, count(e.screen_size)},
select: {e.screen_size, count(e.user_id, :distinct)},
group_by: e.screen_size,
where: e.new_visitor == true and not is_nil(e.screen_size)
where: not is_nil(e.screen_size)
)
|> Enum.sort(fn {screen_size1, _}, {screen_size2, _} ->
index1 = Enum.find_index(@available_screen_sizes, fn s -> s == screen_size1 end)
@ -160,10 +160,10 @@ defmodule Plausible.Stats do
def countries(site, query, limit \\ 5) do
Repo.all(from e in base_query(site, query),
select: {e.country_code, count(e.country_code)},
select: {e.country_code, count(e.user_id, :distinct)},
group_by: e.country_code,
where: e.new_visitor == true and not is_nil(e.country_code),
order_by: [desc: count(e.country_code)]
where: not is_nil(e.country_code),
order_by: [desc: 2]
)
|> Enum.map(fn {country_code, count} ->
{Plausible.Stats.CountryName.from_iso3166(country_code), count}
@ -174,10 +174,10 @@ defmodule Plausible.Stats do
def browsers(site, query, limit \\ 5) do
Repo.all(from e in base_query(site, query),
select: {e.browser, count(e.browser)},
select: {e.browser, count(e.user_id, :distinct)},
group_by: e.browser,
where: e.new_visitor == true and not is_nil(e.browser),
order_by: [desc: count(e.browser)]
where: not is_nil(e.browser),
order_by: [desc: 2]
)
|> add_percentages
|> Enum.take(limit)
@ -185,10 +185,10 @@ defmodule Plausible.Stats do
def operating_systems(site, query, limit \\ 5) do
Repo.all(from e in base_query(site, query),
select: {e.operating_system, count(e.operating_system)},
select: {e.operating_system, count(e.user_id, :distinct)},
group_by: e.operating_system,
where: e.new_visitor == true and not is_nil(e.operating_system),
order_by: [desc: count(e.operating_system)]
where: not is_nil(e.operating_system),
order_by: [desc: 2]
)
|> add_percentages
|> Enum.take(limit)
@ -203,7 +203,15 @@ defmodule Plausible.Stats do
)
end
def goal_conversions(site, query, _limit \\ 5) do
def goal_conversions(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do
Repo.all(from e in base_query(site, query),
select: count(e.user_id, :distinct),
group_by: e.name,
order_by: [desc: 1]
) |> Enum.map(fn count -> %{name: goal, count: count} end)
end
def goal_conversions(site, query) 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)
@ -211,21 +219,12 @@ defmodule Plausible.Stats do
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,
from e in base_query(site, query, events),
group_by: e.name,
select: %{name: e.name, count: count(e.user_id, :distinct)}
)
@ -235,21 +234,12 @@ defmodule Plausible.Stats do
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",
from e in base_query(site, query),
where: e.pathname in ^pages,
group_by: e.pathname,
select: %{name: fragment("concat('Visit ', ?)", e.pathname), count: count(e.user_id, :distinct)}
@ -263,7 +253,7 @@ defmodule Plausible.Stats do
Enum.sort_by(conversions, fn conversion -> -conversion[:count] end)
end
defp base_query(site, query) do
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")
@ -272,11 +262,35 @@ defmodule Plausible.Stats do
last_datetime = Timex.to_datetime(last, site.timezone)
|> Timex.Timezone.convert("UTC")
from(e in Plausible.Event,
where: e.name == "pageview",
{goal_event, path} = event_name_for_goal(query)
q = from(e in Plausible.Event,
where: e.hostname == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime
)
q = if path do
from(e in q, where: e.pathname == ^path)
else
q
end
if goal_event do
from(e in q, where: e.name == ^goal_event)
else
from(e in q, where: e.name in ^events)
end
end
defp event_name_for_goal(query) do
case query.filters["goal"] do
"Visit " <> page ->
{"pageview", page}
goal when is_binary(goal) ->
{goal, nil}
_ ->
{nil, nil}
end
end
defp transform_keys(map, fun) do

View File

@ -32,12 +32,15 @@ defmodule PlausibleWeb.Api.StatsController do
json(conn, Stats.top_referrers(site, query, params["limit"] || 5))
end
@google_api Application.fetch_env!(:plausible, :google_api)
def referrer_drilldown(conn, %{"referrer" => "Google"} = params) do
site = conn.assigns[:site] |> Repo.preload(:google_auth)
query = Stats.Query.from(site.timezone, params)
search_terms = if site.google_auth && site.google_auth.property do
Plausible.Google.Api.fetch_stats(site.google_auth, query)
search_terms = if site.google_auth && site.google_auth.property && !query.filters["goal"] do
@google_api.fetch_stats(site.google_auth, query)
end
case search_terms do

View File

@ -37,19 +37,24 @@ defmodule Plausible.Stats.QueryTest do
assert q.step_type == "month"
end
test "defaults to 6 months format" do
assert Query.from(@tz, %{}) == Query.from(@tz, %{"period" => "6mo"})
test "parses 6 month format" do
q = Query.from(@tz, %{"period" => "6mo"})
assert q.date_range.first == Timex.shift(Timex.today(), months: -5) |> Timex.beginning_of_month()
assert q.date_range.last == Timex.today()
assert q.step_type == "month"
end
test "parses custom format" do
q = Query.from(@tz, %{
"period" => "custom",
"from" => "2019-01-01",
"to" => "2019-02-01"
})
test "defaults to 30 days format" do
assert Query.from(@tz, %{}) == Query.from(@tz, %{"period" => "30d"})
end
assert q.date_range.first == ~D[2019-01-01]
assert q.date_range.last == ~D[2019-02-01]
assert q.step_type == "date"
describe "filters" do
test "parses goal filter" do
filters = Jason.encode!(%{"goal" => "Signup"})
q = Query.from(@tz, %{"period" => "3mo", "filters" => filters})
assert q.filters["goal"] == "Signup"
end
end
end

View File

@ -54,4 +54,23 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
]
end
end
describe "GET /api/stats/:domain/conversions - with goal filter" do
setup [:create_user, :log_in, :create_site]
test "returns only the conversion tha is filtered for", %{conn: conn, site: site} do
insert(:goal, %{domain: site.domain, page_path: "/success"})
insert(:goal, %{domain: site.domain, event_name: "Signup"})
insert(:event, name: "Signup", hostname: site.domain, timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, timestamp: ~N[2019-01-01 01:00:00])
filters = Jason.encode!(%{goal: "Signup"})
conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day&date=2019-01-01&filters=#{filters}")
assert json_response(conn, 200) == [
%{"name" => "Signup", "count" => 1},
]
end
end
end

View File

@ -5,11 +5,11 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
describe "GET /api/stats/:domain/referrers" do
setup [:create_user, :log_in, :create_site]
test "returns top referrer sources by new visitors", %{conn: conn, site: site} do
insert(:pageview, hostname: site.domain, referrer_source: "Google", new_visitor: true, timestamp: ~N[2019-01-01 01:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", new_visitor: false, timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", new_visitor: true, timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Bing", new_visitor: true, timestamp: ~N[2019-01-01 02:00:00])
test "returns top referrer sources by unique visitors", %{conn: conn, site: site} do
pageview1 = insert(:pageview, hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", user_id: pageview1.user_id, timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Bing", timestamp: ~N[2019-01-01 02:00:00])
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01")
@ -18,6 +18,32 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
%{"name" => "Bing", "count" => 1},
]
end
test "filters referrers for a custom goal", %{conn: conn, site: site} do
insert(:event, name: "Signup", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "Signup", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
filters = Jason.encode!(%{goal: "Signup"})
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01&filters=#{filters}")
assert json_response(conn, 200) == [
%{"name" => "Google", "count" => 2},
]
end
test "filters referrers for a pageview goal", %{conn: conn, site: site} do
insert(:pageview, pathname: "/register", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:pageview, pathname: "/register", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:pageview, pathname: "/irrelevant", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
filters = Jason.encode!(%{goal: "Visit /register"})
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01&filters=#{filters}")
assert json_response(conn, 200) == [
%{"name" => "Google", "count" => 2},
]
end
end
describe "GET /api/stats/:domain/referrer-drilldown" do
@ -58,5 +84,19 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
]
}
end
test "gets keywords from Google", %{conn: conn, user: user, site: site} do
insert(:google_auth, user: user, user: user,site: site, property: "sc-domain:example.com")
insert(:pageview, hostname: site.domain, referrer: "google.com", referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:pageview, hostname: site.domain, referrer: "google.com", referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
conn = get(conn, "/api/stats/#{site.domain}/referrers/Google?period=day&date=2019-01-01")
{:ok, terms} = Plausible.Google.Api.Mock.fetch_stats(nil, nil)
assert json_response(conn, 200) == %{
"total_visitors" => 2,
"search_terms" => terms
}
end
end
end

View File

@ -0,0 +1,8 @@
defmodule Plausible.Google.Api.Mock do
def fetch_stats(_auth, _query) do
{:ok, [
%{"name" => "simple web analytics", "count" => 6},
%{"name" => "open-source analytics", "count" => 2},
]}
end
end