add dashboard filtering to analytics
All checks were successful
deploy / deploy (push) Successful in 1m19s
All checks were successful
deploy / deploy (push) Successful in 1m19s
Click any row in pages, sources, countries, or devices tables to filter the entire dashboard by that dimension. Active filters show as dismissible chips. Filters thread through all queries including previous-period deltas. 1050 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6eda1de1bc
commit
7ceee9c814
28
PROGRESS.md
28
PROGRESS.md
@ -22,6 +22,7 @@
|
|||||||
- Denormalized product fields (cheapest_price, in_stock, on_sale) for DB-level sort/filter
|
- Denormalized product fields (cheapest_price, in_stock, on_sale) for DB-level sort/filter
|
||||||
- Transactional emails (order confirmation, shipping notification)
|
- Transactional emails (order confirmation, shipping notification)
|
||||||
- Demo content polished and ready for production
|
- Demo content polished and ready for production
|
||||||
|
- Privacy-first analytics with comparison mode (period deltas on stat cards)
|
||||||
|
|
||||||
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). Setup and launch readiness complete — `/setup` onboarding page, dashboard launch checklist, provider registry, provider-agnostic setup status.
|
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). Setup and launch readiness complete — `/setup` onboarding page, dashboard launch checklist, provider registry, provider-agnostic setup status.
|
||||||
|
|
||||||
@ -87,7 +88,11 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
|
|||||||
| | **Bugs / polish** | | | |
|
| | **Bugs / polish** | | | |
|
||||||
| ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done |
|
| ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done |
|
||||||
|
|
||||||
**All tasks complete.** No remaining work in the task list.
|
| | **Analytics v2** ([plan](docs/plans/analytics-v2.md)) | | | |
|
||||||
|
| ~~52~~ | ~~Comparison mode: period deltas on stat cards~~ | — | 1h | done |
|
||||||
|
| 53 | Dashboard filtering (click to filter by dimension) | 52 | 3h | planned |
|
||||||
|
| 54 | CSV export | 52 | 1.5h | planned |
|
||||||
|
| 55 | Entry/exit pages panel | — | 1h | planned |
|
||||||
|
|
||||||
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
|
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
|
||||||
|
|
||||||
@ -114,7 +119,7 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp
|
|||||||
|
|
||||||
### Tier 3 — Compliance & quality
|
### Tier 3 — Compliance & quality
|
||||||
|
|
||||||
10. **Privacy-respecting analytics** — Self-hosted, cookie-free analytics. Plausible, Umami, or a lightweight custom solution. No Google Analytics, no third-party tracking. GDPR-friendly by design.
|
10. ~~**Privacy-respecting analytics**~~ — ✅ In progress. Custom cookie-free analytics with pageviews, e-commerce funnel, device/browser/OS/country tracking. Period comparison deltas on stat cards. Next: dashboard filtering, CSV export, entry/exit pages.
|
||||||
11. **AGPL licensing & code hosting** — Currently AGPL-3.0. Decide on GitHub vs Codeberg vs self-hosted Forgejo. Set up proper LICENSE file, contribution guidelines, and release process.
|
11. **AGPL licensing & code hosting** — Currently AGPL-3.0. Decide on GitHub vs Codeberg vs self-hosted Forgejo. Set up proper LICENSE file, contribution guidelines, and release process.
|
||||||
12. **Security (Paraxial.io)** — Runtime application security monitoring for Elixir. Bot detection, rate limiting, vulnerability scanning. Evaluate whether it fits the self-hosted model.
|
12. **Security (Paraxial.io)** — Runtime application security monitoring for Elixir. Bot detection, rate limiting, vulnerability scanning. Evaluate whether it fits the self-hosted model.
|
||||||
|
|
||||||
@ -374,7 +379,22 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
|
|||||||
- [x] `mix bench.sqlite` task with `--prod`, `--scale`, `--pool-size`, `--busy-timeout` options
|
- [x] `mix bench.sqlite` task with `--prod`, `--scale`, `--pool-size`, `--busy-timeout` options
|
||||||
- [x] PRAGMA tuning across dev/test/prod: `default_transaction_mode: :immediate`, `journal_size_limit: 64MB`, `custom_pragmas: [mmap_size: 128MB]`
|
- [x] PRAGMA tuning across dev/test/prod: `default_transaction_mode: :immediate`, `journal_size_limit: 64MB`, `custom_pragmas: [mmap_size: 128MB]`
|
||||||
- [x] Benchmarks confirmed: IMMEDIATE mode eliminates transaction upgrade BUSY errors (0% vs 73-80% failure rate under contention), prod mode 5-12x faster than dev, 300 concurrent mixed requests with zero errors
|
- [x] Benchmarks confirmed: IMMEDIATE mode eliminates transaction upgrade BUSY errors (0% vs 73-80% failure rate under contention), prod mode 5-12x faster than dev, 300 concurrent mixed requests with zero errors
|
||||||
- [x] 972 tests total (5 excluded: 3 benchmarks + 2 correctness)
|
- [x] 1041 tests total (5 excluded: 3 benchmarks + 2 correctness)
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
**Status:** In progress (v2)
|
||||||
|
|
||||||
|
- [x] Privacy-first, cookie-free analytics (2bd2e61)
|
||||||
|
- [x] E-commerce funnel events: product_view, add_to_cart, checkout_start, purchase (f91b47f)
|
||||||
|
- [x] Browser, OS, screen size collection (f91b47f)
|
||||||
|
- [x] HTML/CSS bar chart with hourly today view and readable labels (08fcd60)
|
||||||
|
- [x] Period comparison deltas on stat cards (6eda1de)
|
||||||
|
- [x] 2-year demo seed data with growth curve (6eda1de)
|
||||||
|
- [ ] Dashboard filtering (click referrer/country/device to filter all panels)
|
||||||
|
- [ ] CSV export
|
||||||
|
- [ ] Entry/exit pages panel
|
||||||
|
|
||||||
|
See: [docs/plans/analytics-v2.md](docs/plans/analytics-v2.md) for v2 plan
|
||||||
|
|
||||||
### Page Editor
|
### Page Editor
|
||||||
**Status:** Future (Tier 4)
|
**Status:** Future (Tier 4)
|
||||||
@ -389,6 +409,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|
|||||||
|
|
||||||
| Feature | Commit | Notes |
|
| Feature | Commit | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
|
| Analytics comparison mode | 6eda1de | Period deltas on stat cards, 2-year seed data, zero-baseline handling, 1041 tests |
|
||||||
|
| Analytics v1 + chart improvements | 2bd2e61..08fcd60 | Cookie-free analytics, e-commerce funnel, HTML/CSS bar chart, hourly today view |
|
||||||
| Printify + Printful client tests | b0aed4c, a45e85e | Req.Test stubs for both HTTP clients, provider integration tests, mockup enricher tests, 972 tests |
|
| Printify + Printful client tests | b0aed4c, a45e85e | Req.Test stubs for both HTTP clients, provider integration tests, mockup enricher tests, 972 tests |
|
||||||
| SQLite production tuning | 162bf4c, 19d8c7d | Concurrency tests, `mix bench.sqlite` task, IMMEDIATE transactions, mmap 128MB, journal_size_limit 64MB, 898 tests |
|
| SQLite production tuning | 162bf4c, 19d8c7d | Concurrency tests, `mix bench.sqlite` task, IMMEDIATE transactions, mmap 128MB, journal_size_limit 64MB, 898 tests |
|
||||||
| Per-colour images + gallery filtering | 0fe48ba | colour column on product_images, per-colour mockup enrichment, PDP gallery filtering, Printify option filtering, hero colour ordering, 821 tests |
|
| Per-colour images + gallery filtering | 0fe48ba | colour column on product_images, per-colour mockup enrichment, PDP gallery filtering, Printify option filtering, hero colour ordering, 821 tests |
|
||||||
|
|||||||
@ -1,23 +1,25 @@
|
|||||||
# Analytics dashboard v2
|
# Analytics dashboard v2
|
||||||
|
|
||||||
Status: Planned
|
Status: In progress
|
||||||
|
|
||||||
## Current state (v1)
|
## Current state (v1)
|
||||||
|
|
||||||
- Unique visitors, pageviews, bounce rate, visit duration
|
- Unique visitors, pageviews, bounce rate, visit duration
|
||||||
- SVG bar chart for visitor trends
|
- HTML/CSS bar chart with tooltip hover, hourly today view, readable labels
|
||||||
- Date range picker (Today, 7d, 30d, 12m)
|
- Date range picker (Today, 7d, 30d, 12m)
|
||||||
- Top pages, sources/referrers, countries, devices
|
- Top pages, sources/referrers, countries, devices
|
||||||
- E-commerce conversion funnel (product view → cart → checkout → purchase)
|
- E-commerce conversion funnel (product view → cart → checkout → purchase)
|
||||||
- Data collection: UTMs, referrers, browser, OS, screen size, full e-commerce funnel
|
- Data collection: UTMs, referrers, browser, OS, screen size, full e-commerce funnel
|
||||||
|
- Period comparison deltas on stat cards (↑12%, ↓3%)
|
||||||
|
- Demo seed data spanning 2 years for meaningful comparisons
|
||||||
|
|
||||||
## Improvements (priority order)
|
## Improvements (priority order)
|
||||||
|
|
||||||
### 1. Comparison mode
|
### 1. Comparison mode
|
||||||
|
|
||||||
Compare current period vs previous period. Show deltas on each metric (↑12%, ↓3%). Already have date ranges — just query the previous period in parallel and render the difference. Low effort, high value.
|
~~Compare current period vs previous period.~~ Done (6eda1de). Each stat card shows percentage delta vs equivalent previous period. Handles zero-baseline (shows "new"), caps extreme deltas at >999%. Bounce rate uses inverted colour logic (lower = green). Seed data extended to 2 years.
|
||||||
|
|
||||||
**Files:** `lib/berrypod/analytics.ex` (queries), `lib/berrypod_web/live/admin/analytics.ex` (UI)
|
**Files:** `lib/berrypod_web/live/admin/analytics.ex` (date range calc, delta display), `priv/repo/seeds/analytics.exs` (2-year data)
|
||||||
|
|
||||||
### 2. Dashboard filtering
|
### 2. Dashboard filtering
|
||||||
|
|
||||||
|
|||||||
@ -56,8 +56,8 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Counts unique visitors in the given date range.
|
Counts unique visitors in the given date range.
|
||||||
"""
|
"""
|
||||||
def count_visitors(date_range) do
|
def count_visitors(date_range, filters \\ %{}) do
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> select([e], count(e.visitor_hash, :distinct))
|
|> select([e], count(e.visitor_hash, :distinct))
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
@ -66,8 +66,8 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Counts total pageviews in the given date range.
|
Counts total pageviews in the given date range.
|
||||||
"""
|
"""
|
||||||
def count_pageviews(date_range) do
|
def count_pageviews(date_range, filters \\ %{}) do
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> select([e], count())
|
|> select([e], count())
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
@ -78,9 +78,9 @@ defmodule Berrypod.Analytics do
|
|||||||
|
|
||||||
Bounce = a session with only one pageview.
|
Bounce = a session with only one pageview.
|
||||||
"""
|
"""
|
||||||
def bounce_rate(date_range) do
|
def bounce_rate(date_range, filters \\ %{}) do
|
||||||
sessions_query =
|
sessions_query =
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> group_by([e], e.session_hash)
|
|> group_by([e], e.session_hash)
|
||||||
|> select([e], %{
|
|> select([e], %{
|
||||||
@ -107,9 +107,9 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Average visit duration in seconds.
|
Average visit duration in seconds.
|
||||||
"""
|
"""
|
||||||
def avg_duration(date_range) do
|
def avg_duration(date_range, filters \\ %{}) do
|
||||||
durations_query =
|
durations_query =
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> group_by([e], e.session_hash)
|
|> group_by([e], e.session_hash)
|
||||||
|> having([e], count() > 1)
|
|> having([e], count() > 1)
|
||||||
@ -136,8 +136,8 @@ defmodule Berrypod.Analytics do
|
|||||||
|
|
||||||
Returns a list of `%{date: ~D[], visitors: integer}` maps.
|
Returns a list of `%{date: ~D[], visitors: integer}` maps.
|
||||||
"""
|
"""
|
||||||
def visitors_by_date(date_range) do
|
def visitors_by_date(date_range, filters \\ %{}) do
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> group_by([e], fragment("date(?)", e.inserted_at))
|
|> group_by([e], fragment("date(?)", e.inserted_at))
|
||||||
|> select([e], %{
|
|> select([e], %{
|
||||||
@ -153,9 +153,9 @@ defmodule Berrypod.Analytics do
|
|||||||
|
|
||||||
Returns a list of `%{hour: integer, visitors: integer}` maps for all 24 hours.
|
Returns a list of `%{hour: integer, visitors: integer}` maps for all 24 hours.
|
||||||
"""
|
"""
|
||||||
def visitors_by_hour(date_range) do
|
def visitors_by_hour(date_range, filters \\ %{}) do
|
||||||
counts =
|
counts =
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> group_by([e], fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at))
|
|> group_by([e], fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at))
|
||||||
|> select([e], %{
|
|> select([e], %{
|
||||||
@ -174,8 +174,11 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Top pages by unique visitors.
|
Top pages by unique visitors.
|
||||||
"""
|
"""
|
||||||
def top_pages(date_range, limit \\ 10) do
|
def top_pages(date_range, opts \\ []) do
|
||||||
base_query(date_range)
|
limit = Keyword.get(opts, :limit, 10)
|
||||||
|
filters = Keyword.get(opts, :filters, %{})
|
||||||
|
|
||||||
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> group_by([e], e.pathname)
|
|> group_by([e], e.pathname)
|
||||||
|> select([e], %{
|
|> select([e], %{
|
||||||
@ -191,8 +194,11 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Top referrer sources by unique visitors.
|
Top referrer sources by unique visitors.
|
||||||
"""
|
"""
|
||||||
def top_sources(date_range, limit \\ 10) do
|
def top_sources(date_range, opts \\ []) do
|
||||||
base_query(date_range)
|
limit = Keyword.get(opts, :limit, 10)
|
||||||
|
filters = Keyword.get(opts, :filters, %{})
|
||||||
|
|
||||||
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> where([e], not is_nil(e.referrer_source))
|
|> where([e], not is_nil(e.referrer_source))
|
||||||
|> group_by([e], e.referrer_source)
|
|> group_by([e], e.referrer_source)
|
||||||
@ -208,8 +214,11 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Top referrer domains by unique visitors.
|
Top referrer domains by unique visitors.
|
||||||
"""
|
"""
|
||||||
def top_referrers(date_range, limit \\ 10) do
|
def top_referrers(date_range, opts \\ []) do
|
||||||
base_query(date_range)
|
limit = Keyword.get(opts, :limit, 10)
|
||||||
|
filters = Keyword.get(opts, :filters, %{})
|
||||||
|
|
||||||
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> where([e], not is_nil(e.referrer))
|
|> where([e], not is_nil(e.referrer))
|
||||||
|> group_by([e], e.referrer)
|
|> group_by([e], e.referrer)
|
||||||
@ -225,8 +234,11 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Country breakdown by unique visitors.
|
Country breakdown by unique visitors.
|
||||||
"""
|
"""
|
||||||
def top_countries(date_range, limit \\ 10) do
|
def top_countries(date_range, opts \\ []) do
|
||||||
base_query(date_range)
|
limit = Keyword.get(opts, :limit, 10)
|
||||||
|
filters = Keyword.get(opts, :filters, %{})
|
||||||
|
|
||||||
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> where([e], not is_nil(e.country_code))
|
|> where([e], not is_nil(e.country_code))
|
||||||
|> group_by([e], e.country_code)
|
|> group_by([e], e.country_code)
|
||||||
@ -242,10 +254,11 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Device breakdown by the given dimension (:browser, :os, or :screen_size).
|
Device breakdown by the given dimension (:browser, :os, or :screen_size).
|
||||||
"""
|
"""
|
||||||
def device_breakdown(date_range, dimension) when dimension in [:browser, :os, :screen_size] do
|
def device_breakdown(date_range, dimension, filters \\ %{})
|
||||||
|
when dimension in [:browser, :os, :screen_size] do
|
||||||
field = dimension
|
field = dimension
|
||||||
|
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "pageview")
|
|> where([e], e.name == "pageview")
|
||||||
|> where([e], not is_nil(field(e, ^field)))
|
|> where([e], not is_nil(field(e, ^field)))
|
||||||
|> group_by([e], field(e, ^field))
|
|> group_by([e], field(e, ^field))
|
||||||
@ -262,9 +275,9 @@ defmodule Berrypod.Analytics do
|
|||||||
|
|
||||||
Returns `%{product_views: n, add_to_carts: n, checkouts: n, purchases: n}`.
|
Returns `%{product_views: n, add_to_carts: n, checkouts: n, purchases: n}`.
|
||||||
"""
|
"""
|
||||||
def funnel(date_range) do
|
def funnel(date_range, filters \\ %{}) do
|
||||||
counts =
|
counts =
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"])
|
|> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"])
|
||||||
|> group_by([e], e.name)
|
|> group_by([e], e.name)
|
||||||
|> select([e], {e.name, count(e.visitor_hash, :distinct)})
|
|> select([e], {e.name, count(e.visitor_hash, :distinct)})
|
||||||
@ -282,8 +295,8 @@ defmodule Berrypod.Analytics do
|
|||||||
@doc """
|
@doc """
|
||||||
Total revenue in the given date range (pence).
|
Total revenue in the given date range (pence).
|
||||||
"""
|
"""
|
||||||
def total_revenue(date_range) do
|
def total_revenue(date_range, filters \\ %{}) do
|
||||||
base_query(date_range)
|
base_query(date_range, filters)
|
||||||
|> where([e], e.name == "purchase")
|
|> where([e], e.name == "purchase")
|
||||||
|> select([e], coalesce(sum(e.revenue), 0))
|
|> select([e], coalesce(sum(e.revenue), 0))
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
@ -299,9 +312,24 @@ defmodule Berrypod.Analytics do
|
|||||||
|
|
||||||
# ── Private ──
|
# ── Private ──
|
||||||
|
|
||||||
defp base_query({start_date, end_date}) do
|
defp base_query({start_date, end_date}, filters) do
|
||||||
from(e in Event,
|
from(e in Event,
|
||||||
where: e.inserted_at >= ^start_date and e.inserted_at < ^end_date
|
where: e.inserted_at >= ^start_date and e.inserted_at < ^end_date
|
||||||
)
|
)
|
||||||
|
|> apply_filters(filters)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_filters(query, filters) when map_size(filters) == 0, do: query
|
||||||
|
|
||||||
|
defp apply_filters(query, filters) do
|
||||||
|
Enum.reduce(filters, query, fn
|
||||||
|
{:pathname, v}, q -> where(q, [e], e.pathname == ^v)
|
||||||
|
{:referrer_source, v}, q -> where(q, [e], e.referrer_source == ^v)
|
||||||
|
{:country_code, v}, q -> where(q, [e], e.country_code == ^v)
|
||||||
|
{:browser, v}, q -> where(q, [e], e.browser == ^v)
|
||||||
|
{:os, v}, q -> where(q, [e], e.os == ^v)
|
||||||
|
{:screen_size, v}, q -> where(q, [e], e.screen_size == ^v)
|
||||||
|
_, q -> q
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,6 +11,8 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
"12m" => 364
|
"12m" => 364
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
@ -18,6 +20,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
|> assign(:page_title, "Analytics")
|
|> assign(:page_title, "Analytics")
|
||||||
|> assign(:period, "30d")
|
|> assign(:period, "30d")
|
||||||
|> assign(:tab, "pages")
|
|> assign(:tab, "pages")
|
||||||
|
|> assign(:filters, %{})
|
||||||
|> load_analytics("30d")}
|
|> load_analytics("30d")}
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -35,26 +38,54 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
{:noreply, assign(socket, :tab, tab)}
|
{:noreply, assign(socket, :tab, tab)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("add_filter", %{"dimension" => dim, "value" => val}, socket)
|
||||||
|
when dim in @filterable_dimensions do
|
||||||
|
filters = Map.put(socket.assigns.filters, String.to_existing_atom(dim), val)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:filters, filters)
|
||||||
|
|> load_analytics(socket.assigns.period)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("remove_filter", %{"dimension" => dim}, socket)
|
||||||
|
when dim in @filterable_dimensions do
|
||||||
|
filters = Map.delete(socket.assigns.filters, String.to_existing_atom(dim))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:filters, filters)
|
||||||
|
|> load_analytics(socket.assigns.period)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("clear_filters", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:filters, %{})
|
||||||
|
|> load_analytics(socket.assigns.period)}
|
||||||
|
end
|
||||||
|
|
||||||
# ── Data loading ──
|
# ── Data loading ──
|
||||||
|
|
||||||
defp load_analytics(socket, period) do
|
defp load_analytics(socket, period) do
|
||||||
range = date_range(period)
|
range = date_range(period)
|
||||||
prev_range = previous_date_range(period)
|
prev_range = previous_date_range(period)
|
||||||
|
filters = socket.assigns.filters
|
||||||
|
|
||||||
trend_data =
|
trend_data =
|
||||||
if period == "today",
|
if period == "today",
|
||||||
do: Analytics.visitors_by_hour(range),
|
do: Analytics.visitors_by_hour(range, filters),
|
||||||
else: Analytics.visitors_by_date(range)
|
else: Analytics.visitors_by_date(range, filters)
|
||||||
|
|
||||||
visitors = Analytics.count_visitors(range)
|
visitors = Analytics.count_visitors(range, filters)
|
||||||
pageviews = Analytics.count_pageviews(range)
|
pageviews = Analytics.count_pageviews(range, filters)
|
||||||
bounce_rate = Analytics.bounce_rate(range)
|
bounce_rate = Analytics.bounce_rate(range, filters)
|
||||||
avg_duration = Analytics.avg_duration(range)
|
avg_duration = Analytics.avg_duration(range, filters)
|
||||||
|
|
||||||
prev_visitors = Analytics.count_visitors(prev_range)
|
prev_visitors = Analytics.count_visitors(prev_range, filters)
|
||||||
prev_pageviews = Analytics.count_pageviews(prev_range)
|
prev_pageviews = Analytics.count_pageviews(prev_range, filters)
|
||||||
prev_bounce_rate = Analytics.bounce_rate(prev_range)
|
prev_bounce_rate = Analytics.bounce_rate(prev_range, filters)
|
||||||
prev_avg_duration = Analytics.avg_duration(prev_range)
|
prev_avg_duration = Analytics.avg_duration(prev_range, filters)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:visitors, visitors)
|
|> assign(:visitors, visitors)
|
||||||
@ -67,15 +98,15 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
|> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration))
|
|> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration))
|
||||||
|> assign(:trend_data, trend_data)
|
|> assign(:trend_data, trend_data)
|
||||||
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|
||||||
|> assign(:top_pages, Analytics.top_pages(range))
|
|> assign(:top_pages, Analytics.top_pages(range, filters: filters))
|
||||||
|> assign(:top_sources, Analytics.top_sources(range))
|
|> assign(:top_sources, Analytics.top_sources(range, filters: filters))
|
||||||
|> assign(:top_referrers, Analytics.top_referrers(range))
|
|> assign(:top_referrers, Analytics.top_referrers(range, filters: filters))
|
||||||
|> assign(:top_countries, Analytics.top_countries(range))
|
|> assign(:top_countries, Analytics.top_countries(range, filters: filters))
|
||||||
|> assign(:browsers, Analytics.device_breakdown(range, :browser))
|
|> assign(:browsers, Analytics.device_breakdown(range, :browser, filters))
|
||||||
|> assign(:oses, Analytics.device_breakdown(range, :os))
|
|> assign(:oses, Analytics.device_breakdown(range, :os, filters))
|
||||||
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size))
|
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size, filters))
|
||||||
|> assign(:funnel, Analytics.funnel(range))
|
|> assign(:funnel, Analytics.funnel(range, filters))
|
||||||
|> assign(:revenue, Analytics.total_revenue(range))
|
|> assign(:revenue, Analytics.total_revenue(range, filters))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp date_range(period) do
|
defp date_range(period) do
|
||||||
@ -122,6 +153,27 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%!-- Active filters --%>
|
||||||
|
<div
|
||||||
|
:if={@filters != %{}}
|
||||||
|
id="analytics-filters"
|
||||||
|
style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;"
|
||||||
|
>
|
||||||
|
<.filter_chip
|
||||||
|
:for={{dim, val} <- @filters}
|
||||||
|
dimension={dim}
|
||||||
|
value={val}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:if={map_size(@filters) > 1}
|
||||||
|
phx-click="clear_filters"
|
||||||
|
class="admin-btn admin-btn-sm"
|
||||||
|
style="font-size: 0.75rem;"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Stat cards --%>
|
<%!-- Stat cards --%>
|
||||||
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
|
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
|
||||||
<.stat_card
|
<.stat_card
|
||||||
@ -245,6 +297,36 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Filter chip ──
|
||||||
|
|
||||||
|
attr :dimension, :atom, required: true
|
||||||
|
attr :value, :string, required: true
|
||||||
|
|
||||||
|
defp filter_chip(assigns) do
|
||||||
|
assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<span style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-base-200, #e5e5e5); border-radius: 0.25rem;">
|
||||||
|
{@label}
|
||||||
|
<button
|
||||||
|
phx-click="remove_filter"
|
||||||
|
phx-value-dimension={@dimension}
|
||||||
|
style="cursor: pointer; opacity: 0.6; line-height: 1;"
|
||||||
|
aria-label={"Remove #{@label} filter"}
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_label(:pathname, val), do: "Page: #{val}"
|
||||||
|
defp filter_label(:referrer_source, val), do: "Source: #{val}"
|
||||||
|
defp filter_label(:country_code, val), do: "Country: #{country_name(val)}"
|
||||||
|
defp filter_label(:browser, val), do: "Browser: #{val}"
|
||||||
|
defp filter_label(:os, val), do: "OS: #{val}"
|
||||||
|
defp filter_label(:screen_size, val), do: "Screen: #{val}"
|
||||||
|
|
||||||
# ── Bar chart (HTML/CSS bars with readable labels) ──
|
# ── Bar chart (HTML/CSS bars with readable labels) ──
|
||||||
|
|
||||||
attr :data, :list, required: true
|
attr :data, :list, required: true
|
||||||
@ -421,7 +503,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
rows={@top_pages}
|
rows={@top_pages}
|
||||||
empty_message="No page data yet"
|
empty_message="No page data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "Page", key: :pathname},
|
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right},
|
%{label: "Visitors", key: :visitors, align: :right},
|
||||||
%{label: "Pageviews", key: :pageviews, align: :right}
|
%{label: "Pageviews", key: :pageviews, align: :right}
|
||||||
]}
|
]}
|
||||||
@ -436,7 +518,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
rows={@top_sources}
|
rows={@top_sources}
|
||||||
empty_message="No referrer data yet"
|
empty_message="No referrer data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "Source", key: :source},
|
%{label: "Source", key: :source, filter: {:referrer_source, :source}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right}
|
%{label: "Visitors", key: :visitors, align: :right}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -453,13 +535,20 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp tab_content(%{tab: "countries"} = assigns) do
|
defp tab_content(%{tab: "countries"} = assigns) do
|
||||||
|
rows =
|
||||||
|
Enum.map(assigns.top_countries, fn c ->
|
||||||
|
Map.put(c, :display_name, country_name(c.country_code))
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns = assign(assigns, :country_rows, rows)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
|
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
|
||||||
<.detail_table
|
<.detail_table
|
||||||
rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)}
|
rows={@country_rows}
|
||||||
empty_message="No country data yet"
|
empty_message="No country data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "Country", key: :country_code},
|
%{label: "Country", key: :display_name, filter: {:country_code, :country_code}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right}
|
%{label: "Visitors", key: :visitors, align: :right}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -473,7 +562,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
rows={@browsers}
|
rows={@browsers}
|
||||||
empty_message="No browser data yet"
|
empty_message="No browser data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "Browser", key: :name},
|
%{label: "Browser", key: :name, filter: {:browser, :name}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right}
|
%{label: "Visitors", key: :visitors, align: :right}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -484,7 +573,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
rows={@oses}
|
rows={@oses}
|
||||||
empty_message="No OS data yet"
|
empty_message="No OS data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "OS", key: :name},
|
%{label: "OS", key: :name, filter: {:os, :name}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right}
|
%{label: "Visitors", key: :visitors, align: :right}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -493,7 +582,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
rows={@screen_sizes}
|
rows={@screen_sizes}
|
||||||
empty_message="No screen data yet"
|
empty_message="No screen data yet"
|
||||||
columns={[
|
columns={[
|
||||||
%{label: "Size", key: :name},
|
%{label: "Size", key: :name, filter: {:screen_size, :name}},
|
||||||
%{label: "Visitors", key: :visitors, align: :right}
|
%{label: "Visitors", key: :visitors, align: :right}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -540,7 +629,19 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
:for={col <- @columns}
|
:for={col <- @columns}
|
||||||
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
|
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
|
||||||
>
|
>
|
||||||
{Map.get(row, col.key)}
|
<%= if col[:filter] do %>
|
||||||
|
<span
|
||||||
|
class="admin-link"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
phx-click="add_filter"
|
||||||
|
phx-value-dimension={elem(col.filter, 0)}
|
||||||
|
phx-value-value={Map.get(row, elem(col.filter, 1))}
|
||||||
|
>
|
||||||
|
{Map.get(row, col.key)}
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
{Map.get(row, col.key)}
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -231,6 +231,62 @@ defmodule Berrypod.AnalyticsTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "filtering" do
|
||||||
|
test "count_visitors respects country_code filter" do
|
||||||
|
v1 = :crypto.strong_rand_bytes(8)
|
||||||
|
v2 = :crypto.strong_rand_bytes(8)
|
||||||
|
|
||||||
|
insert_event(%{visitor_hash: v1, country_code: "GB"})
|
||||||
|
insert_event(%{visitor_hash: v2, country_code: "US"})
|
||||||
|
|
||||||
|
assert Analytics.count_visitors(today_range(), %{country_code: "GB"}) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "top_pages respects referrer_source filter" do
|
||||||
|
v1 = :crypto.strong_rand_bytes(8)
|
||||||
|
v2 = :crypto.strong_rand_bytes(8)
|
||||||
|
|
||||||
|
insert_event(%{visitor_hash: v1, pathname: "/", referrer_source: "Google"})
|
||||||
|
insert_event(%{visitor_hash: v2, pathname: "/about", referrer_source: "Facebook"})
|
||||||
|
|
||||||
|
pages = Analytics.top_pages(today_range(), filters: %{referrer_source: "Google"})
|
||||||
|
assert length(pages) == 1
|
||||||
|
assert hd(pages).pathname == "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bounce_rate respects filter" do
|
||||||
|
s1 = :crypto.strong_rand_bytes(8)
|
||||||
|
s2 = :crypto.strong_rand_bytes(8)
|
||||||
|
|
||||||
|
# GB visitor bounces (1 pageview)
|
||||||
|
insert_event(%{session_hash: s1, country_code: "GB"})
|
||||||
|
# US visitor doesn't bounce (2 pageviews)
|
||||||
|
insert_event(%{session_hash: s2, country_code: "US", pathname: "/"})
|
||||||
|
insert_event(%{session_hash: s2, country_code: "US", pathname: "/about"})
|
||||||
|
|
||||||
|
assert Analytics.bounce_rate(today_range(), %{country_code: "GB"}) == 100
|
||||||
|
assert Analytics.bounce_rate(today_range(), %{country_code: "US"}) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiple filters combine with AND logic" do
|
||||||
|
v1 = :crypto.strong_rand_bytes(8)
|
||||||
|
v2 = :crypto.strong_rand_bytes(8)
|
||||||
|
|
||||||
|
insert_event(%{visitor_hash: v1, country_code: "GB", browser: "Chrome"})
|
||||||
|
insert_event(%{visitor_hash: v2, country_code: "GB", browser: "Firefox"})
|
||||||
|
|
||||||
|
assert Analytics.count_visitors(today_range(), %{country_code: "GB", browser: "Chrome"}) ==
|
||||||
|
1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown filter keys are ignored" do
|
||||||
|
v1 = :crypto.strong_rand_bytes(8)
|
||||||
|
insert_event(%{visitor_hash: v1})
|
||||||
|
|
||||||
|
assert Analytics.count_visitors(today_range(), %{nonexistent: "val"}) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "delete_events_before/1" do
|
describe "delete_events_before/1" do
|
||||||
test "deletes old events" do
|
test "deletes old events" do
|
||||||
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
||||||
|
|||||||
@ -102,5 +102,63 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
|
|||||||
html = render_click(view, "change_tab", %{"tab" => "funnel"})
|
html = render_click(view, "change_tab", %{"tab" => "funnel"})
|
||||||
assert html =~ "Conversion funnel"
|
assert html =~ "Conversion funnel"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "clicking a source adds a filter chip", %{conn: conn} do
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
Repo.insert_all(Event, [
|
||||||
|
[
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
name: "pageview",
|
||||||
|
pathname: "/",
|
||||||
|
visitor_hash: :crypto.strong_rand_bytes(8),
|
||||||
|
session_hash: :crypto.strong_rand_bytes(8),
|
||||||
|
referrer_source: "Google",
|
||||||
|
inserted_at: now
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
|
||||||
|
|
||||||
|
# Switch to sources tab and click the source
|
||||||
|
render_click(view, "change_tab", %{"tab" => "sources"})
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_click(view, "add_filter", %{"dimension" => "referrer_source", "value" => "Google"})
|
||||||
|
|
||||||
|
assert html =~ "Source: Google"
|
||||||
|
assert has_element?(view, "#analytics-filters")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removing a filter chip clears it", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
|
||||||
|
|
||||||
|
render_click(view, "add_filter", %{"dimension" => "country_code", "value" => "GB"})
|
||||||
|
assert render(view) =~ "Country: United Kingdom"
|
||||||
|
|
||||||
|
html = render_click(view, "remove_filter", %{"dimension" => "country_code"})
|
||||||
|
refute html =~ "Country: United Kingdom"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clear all removes all filters", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
|
||||||
|
|
||||||
|
render_click(view, "add_filter", %{"dimension" => "country_code", "value" => "GB"})
|
||||||
|
render_click(view, "add_filter", %{"dimension" => "browser", "value" => "Chrome"})
|
||||||
|
assert render(view) =~ "Clear all"
|
||||||
|
|
||||||
|
html = render_click(view, "clear_filters", %{})
|
||||||
|
refute html =~ "Country: United Kingdom"
|
||||||
|
refute html =~ "Browser: Chrome"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing period preserves filters", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
|
||||||
|
|
||||||
|
render_click(view, "add_filter", %{"dimension" => "country_code", "value" => "GB"})
|
||||||
|
html = render_click(view, "change_period", %{"period" => "7d"})
|
||||||
|
|
||||||
|
assert html =~ "Country: United Kingdom"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user