diff --git a/PROGRESS.md b/PROGRESS.md index 6c7e05a..3d5acdc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -22,6 +22,7 @@ - Denormalized product fields (cheapest_price, in_stock, on_sale) for DB-level sort/filter - Transactional emails (order confirmation, shipping notification) - 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. @@ -87,7 +88,11 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | | **Bugs / polish** | | | | | ~~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. @@ -114,7 +119,7 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 16 of 18 comp ### 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. 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] 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] 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 **Status:** Future (Tier 4) @@ -389,6 +409,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design | 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 | | 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 | diff --git a/docs/plans/analytics-v2.md b/docs/plans/analytics-v2.md index 6a3ff92..8a62069 100644 --- a/docs/plans/analytics-v2.md +++ b/docs/plans/analytics-v2.md @@ -1,23 +1,25 @@ # Analytics dashboard v2 -Status: Planned +Status: In progress ## Current state (v1) - 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) - Top pages, sources/referrers, countries, devices - E-commerce conversion funnel (product view → cart → checkout → purchase) - 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) ### 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 diff --git a/lib/berrypod/analytics.ex b/lib/berrypod/analytics.ex index 84dffde..7e49431 100644 --- a/lib/berrypod/analytics.ex +++ b/lib/berrypod/analytics.ex @@ -56,8 +56,8 @@ defmodule Berrypod.Analytics do @doc """ Counts unique visitors in the given date range. """ - def count_visitors(date_range) do - base_query(date_range) + def count_visitors(date_range, filters \\ %{}) do + base_query(date_range, filters) |> where([e], e.name == "pageview") |> select([e], count(e.visitor_hash, :distinct)) |> Repo.one() @@ -66,8 +66,8 @@ defmodule Berrypod.Analytics do @doc """ Counts total pageviews in the given date range. """ - def count_pageviews(date_range) do - base_query(date_range) + def count_pageviews(date_range, filters \\ %{}) do + base_query(date_range, filters) |> where([e], e.name == "pageview") |> select([e], count()) |> Repo.one() @@ -78,9 +78,9 @@ defmodule Berrypod.Analytics do Bounce = a session with only one pageview. """ - def bounce_rate(date_range) do + def bounce_rate(date_range, filters \\ %{}) do sessions_query = - base_query(date_range) + base_query(date_range, filters) |> where([e], e.name == "pageview") |> group_by([e], e.session_hash) |> select([e], %{ @@ -107,9 +107,9 @@ defmodule Berrypod.Analytics do @doc """ Average visit duration in seconds. """ - def avg_duration(date_range) do + def avg_duration(date_range, filters \\ %{}) do durations_query = - base_query(date_range) + base_query(date_range, filters) |> where([e], e.name == "pageview") |> group_by([e], e.session_hash) |> having([e], count() > 1) @@ -136,8 +136,8 @@ defmodule Berrypod.Analytics do Returns a list of `%{date: ~D[], visitors: integer}` maps. """ - def visitors_by_date(date_range) do - base_query(date_range) + def visitors_by_date(date_range, filters \\ %{}) do + base_query(date_range, filters) |> where([e], e.name == "pageview") |> group_by([e], fragment("date(?)", e.inserted_at)) |> select([e], %{ @@ -153,9 +153,9 @@ defmodule Berrypod.Analytics do 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 = - base_query(date_range) + base_query(date_range, filters) |> where([e], e.name == "pageview") |> group_by([e], fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at)) |> select([e], %{ @@ -174,8 +174,11 @@ defmodule Berrypod.Analytics do @doc """ Top pages by unique visitors. """ - def top_pages(date_range, limit \\ 10) do - base_query(date_range) + def top_pages(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + base_query(date_range, filters) |> where([e], e.name == "pageview") |> group_by([e], e.pathname) |> select([e], %{ @@ -191,8 +194,11 @@ defmodule Berrypod.Analytics do @doc """ Top referrer sources by unique visitors. """ - def top_sources(date_range, limit \\ 10) do - base_query(date_range) + def top_sources(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + base_query(date_range, filters) |> where([e], e.name == "pageview") |> where([e], not is_nil(e.referrer_source)) |> group_by([e], e.referrer_source) @@ -208,8 +214,11 @@ defmodule Berrypod.Analytics do @doc """ Top referrer domains by unique visitors. """ - def top_referrers(date_range, limit \\ 10) do - base_query(date_range) + def top_referrers(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + base_query(date_range, filters) |> where([e], e.name == "pageview") |> where([e], not is_nil(e.referrer)) |> group_by([e], e.referrer) @@ -225,8 +234,11 @@ defmodule Berrypod.Analytics do @doc """ Country breakdown by unique visitors. """ - def top_countries(date_range, limit \\ 10) do - base_query(date_range) + def top_countries(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + base_query(date_range, filters) |> where([e], e.name == "pageview") |> where([e], not is_nil(e.country_code)) |> group_by([e], e.country_code) @@ -242,10 +254,11 @@ defmodule Berrypod.Analytics do @doc """ 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 - base_query(date_range) + base_query(date_range, filters) |> where([e], e.name == "pageview") |> where([e], not is_nil(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}`. """ - def funnel(date_range) do + def funnel(date_range, filters \\ %{}) do counts = - base_query(date_range) + base_query(date_range, filters) |> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"]) |> group_by([e], e.name) |> select([e], {e.name, count(e.visitor_hash, :distinct)}) @@ -282,8 +295,8 @@ defmodule Berrypod.Analytics do @doc """ Total revenue in the given date range (pence). """ - def total_revenue(date_range) do - base_query(date_range) + def total_revenue(date_range, filters \\ %{}) do + base_query(date_range, filters) |> where([e], e.name == "purchase") |> select([e], coalesce(sum(e.revenue), 0)) |> Repo.one() @@ -299,9 +312,24 @@ defmodule Berrypod.Analytics do # ── Private ── - defp base_query({start_date, end_date}) do + defp base_query({start_date, end_date}, filters) do from(e in Event, 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 diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index b018eed..5347a2d 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -11,6 +11,8 @@ defmodule BerrypodWeb.Admin.Analytics do "12m" => 364 } + @filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size) + @impl true def mount(_params, _session, socket) do {:ok, @@ -18,6 +20,7 @@ defmodule BerrypodWeb.Admin.Analytics do |> assign(:page_title, "Analytics") |> assign(:period, "30d") |> assign(:tab, "pages") + |> assign(:filters, %{}) |> load_analytics("30d")} end @@ -35,26 +38,54 @@ defmodule BerrypodWeb.Admin.Analytics do {:noreply, assign(socket, :tab, tab)} 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 ── defp load_analytics(socket, period) do range = date_range(period) prev_range = previous_date_range(period) + filters = socket.assigns.filters trend_data = if period == "today", - do: Analytics.visitors_by_hour(range), - else: Analytics.visitors_by_date(range) + do: Analytics.visitors_by_hour(range, filters), + else: Analytics.visitors_by_date(range, filters) - visitors = Analytics.count_visitors(range) - pageviews = Analytics.count_pageviews(range) - bounce_rate = Analytics.bounce_rate(range) - avg_duration = Analytics.avg_duration(range) + visitors = Analytics.count_visitors(range, filters) + pageviews = Analytics.count_pageviews(range, filters) + bounce_rate = Analytics.bounce_rate(range, filters) + avg_duration = Analytics.avg_duration(range, filters) - prev_visitors = Analytics.count_visitors(prev_range) - prev_pageviews = Analytics.count_pageviews(prev_range) - prev_bounce_rate = Analytics.bounce_rate(prev_range) - prev_avg_duration = Analytics.avg_duration(prev_range) + prev_visitors = Analytics.count_visitors(prev_range, filters) + prev_pageviews = Analytics.count_pageviews(prev_range, filters) + prev_bounce_rate = Analytics.bounce_rate(prev_range, filters) + prev_avg_duration = Analytics.avg_duration(prev_range, filters) socket |> assign(:visitors, visitors) @@ -67,15 +98,15 @@ defmodule BerrypodWeb.Admin.Analytics do |> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration)) |> assign(:trend_data, trend_data) |> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily)) - |> assign(:top_pages, Analytics.top_pages(range)) - |> assign(:top_sources, Analytics.top_sources(range)) - |> assign(:top_referrers, Analytics.top_referrers(range)) - |> assign(:top_countries, Analytics.top_countries(range)) - |> assign(:browsers, Analytics.device_breakdown(range, :browser)) - |> assign(:oses, Analytics.device_breakdown(range, :os)) - |> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size)) - |> assign(:funnel, Analytics.funnel(range)) - |> assign(:revenue, Analytics.total_revenue(range)) + |> assign(:top_pages, Analytics.top_pages(range, filters: filters)) + |> assign(:top_sources, Analytics.top_sources(range, filters: filters)) + |> assign(:top_referrers, Analytics.top_referrers(range, filters: filters)) + |> assign(:top_countries, Analytics.top_countries(range, filters: filters)) + |> assign(:browsers, Analytics.device_breakdown(range, :browser, filters)) + |> assign(:oses, Analytics.device_breakdown(range, :os, filters)) + |> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size, filters)) + |> assign(:funnel, Analytics.funnel(range, filters)) + |> assign(:revenue, Analytics.total_revenue(range, filters)) end defp date_range(period) do @@ -122,6 +153,27 @@ defmodule BerrypodWeb.Admin.Analytics do + <%!-- Active filters --%> +
+ <.filter_chip + :for={{dim, val} <- @filters} + dimension={dim} + value={val} + /> + +
+ <%!-- Stat cards --%>
<.stat_card @@ -245,6 +297,36 @@ defmodule BerrypodWeb.Admin.Analytics do """ 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""" + + {@label} + + + """ + 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) ── attr :data, :list, required: true @@ -421,7 +503,7 @@ defmodule BerrypodWeb.Admin.Analytics do rows={@top_pages} empty_message="No page data yet" columns={[ - %{label: "Page", key: :pathname}, + %{label: "Page", key: :pathname, filter: {:pathname, :pathname}}, %{label: "Visitors", key: :visitors, align: :right}, %{label: "Pageviews", key: :pageviews, align: :right} ]} @@ -436,7 +518,7 @@ defmodule BerrypodWeb.Admin.Analytics do rows={@top_sources} empty_message="No referrer data yet" columns={[ - %{label: "Source", key: :source}, + %{label: "Source", key: :source, filter: {:referrer_source, :source}}, %{label: "Visitors", key: :visitors, align: :right} ]} /> @@ -453,13 +535,20 @@ defmodule BerrypodWeb.Admin.Analytics do end 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"""

Countries

<.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" columns={[ - %{label: "Country", key: :country_code}, + %{label: "Country", key: :display_name, filter: {:country_code, :country_code}}, %{label: "Visitors", key: :visitors, align: :right} ]} /> @@ -473,7 +562,7 @@ defmodule BerrypodWeb.Admin.Analytics do rows={@browsers} empty_message="No browser data yet" columns={[ - %{label: "Browser", key: :name}, + %{label: "Browser", key: :name, filter: {:browser, :name}}, %{label: "Visitors", key: :visitors, align: :right} ]} /> @@ -484,7 +573,7 @@ defmodule BerrypodWeb.Admin.Analytics do rows={@oses} empty_message="No OS data yet" columns={[ - %{label: "OS", key: :name}, + %{label: "OS", key: :name, filter: {:os, :name}}, %{label: "Visitors", key: :visitors, align: :right} ]} /> @@ -493,7 +582,7 @@ defmodule BerrypodWeb.Admin.Analytics do rows={@screen_sizes} empty_message="No screen data yet" columns={[ - %{label: "Size", key: :name}, + %{label: "Size", key: :name, filter: {:screen_size, :name}}, %{label: "Visitors", key: :visitors, align: :right} ]} /> @@ -540,7 +629,19 @@ defmodule BerrypodWeb.Admin.Analytics do :for={col <- @columns} style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"} > - {Map.get(row, col.key)} + <%= if col[:filter] do %> + + {Map.get(row, col.key)} + + <% else %> + {Map.get(row, col.key)} + <% end %> diff --git a/test/berrypod/analytics_test.exs b/test/berrypod/analytics_test.exs index 62da370..fe6646a 100644 --- a/test/berrypod/analytics_test.exs +++ b/test/berrypod/analytics_test.exs @@ -231,6 +231,62 @@ defmodule Berrypod.AnalyticsTest do 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 test "deletes old events" do old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second) diff --git a/test/berrypod_web/live/admin/analytics_test.exs b/test/berrypod_web/live/admin/analytics_test.exs index 67a6836..323d6a5 100644 --- a/test/berrypod_web/live/admin/analytics_test.exs +++ b/test/berrypod_web/live/admin/analytics_test.exs @@ -102,5 +102,63 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do html = render_click(view, "change_tab", %{"tab" => "funnel"}) assert html =~ "Conversion funnel" 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