diff --git a/PROGRESS.md b/PROGRESS.md index 232b00c..6c7e05a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -29,7 +29,7 @@ Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| diff --git a/docs/plans/analytics-v2.md b/docs/plans/analytics-v2.md new file mode 100644 index 0000000..6a3ff92 --- /dev/null +++ b/docs/plans/analytics-v2.md @@ -0,0 +1,50 @@ +# Analytics dashboard v2 + +Status: Planned + +## Current state (v1) + +- Unique visitors, pageviews, bounce rate, visit duration +- SVG bar chart for visitor trends +- 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 + +## 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. + +**Files:** `lib/berrypod/analytics.ex` (queries), `lib/berrypod_web/live/admin/analytics.ex` (UI) + +### 2. Dashboard filtering + +Click any referrer, country, device, or page to filter the whole dashboard by that dimension. Turns a reporting page into an exploration tool. Single most useful feature for understanding traffic. + +**Approach:** Add filter params to the URL (e.g. `?country=GB&source=twitter.com`), thread them through all queries. Render active filters as dismissible chips. + +**Files:** analytics context (all query functions), analytics LiveView (params, UI) + +### 3. CSV export + +Export current dashboard view (respecting date range and filters) as CSV. Dead simple, always requested. + +**Approach:** Add a download button per panel (or one global export). Use a regular controller route that streams CSV. + +**Files:** new controller or plug for CSV response, analytics context for raw data queries + +### 4. Entry/exit pages + +Which pages people land on and leave from. Data likely already exists (first and last pageview per session). Just needs the query and a UI panel. + +**Files:** analytics context (new queries), analytics LiveView (new panel) + +## Future (lower priority) + +- **Real-time dashboard** — live visitor count, active pages (needs PubSub or polling) +- **Line charts** — proper time-series with date axes (replace bar chart) +- **UTM drill-downs** — campaign/medium/source breakdown views +- **Goals/custom events** — user-defined conversion tracking +- **Stats API** — JSON API for embedding elsewhere diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index 0ca7104..09fc5cf 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -361,20 +361,24 @@ defmodule BerrypodWeb.Admin.Analytics do {"Purchase", assigns.funnel.purchases} ] - max_val = steps |> Enum.map(&elem(&1, 1)) |> Enum.max(fn -> 1 end) + top_count = assigns.funnel.product_views + + conversion_rate = + if top_count > 0, + do: Float.round(assigns.funnel.purchases / top_count * 100, 1), + else: 0.0 steps_with_rates = steps |> Enum.with_index() |> Enum.map(fn {{label, count}, i} -> - prev_count = if i > 0, do: elem(Enum.at(steps, i - 1), 1), else: count - rate = if prev_count > 0, do: round(count / prev_count * 100), else: 0 - width_pct = if max_val > 0, do: max(count / max_val * 100, 5), else: 5 + overall_rate = if top_count > 0, do: Float.round(count / top_count * 100, 1), else: 0.0 + width_pct = if top_count > 0, do: max(count / top_count * 100, 5), else: 5 - %{label: label, count: count, rate: rate, width_pct: width_pct, index: i} + %{label: label, count: count, overall_rate: overall_rate, width_pct: width_pct, index: i} end) - assigns = assign(assigns, steps: steps_with_rates) + assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate) ~H"""
- {step.count} + {format_number(step.count)}
0} - style="font-size: 0.75rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);" + style="font-size: 0.8125rem; font-weight: 600;" > - {step.rate}% + {step.overall_rate}%
-
0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;"> - Revenue: {Cart.format_price(@revenue)} +
+ {@conversion_rate}% overall conversion + 0} + style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);" + > + · Revenue: {Cart.format_price(@revenue)} +
""" diff --git a/priv/repo/seeds/analytics.exs b/priv/repo/seeds/analytics.exs new file mode 100644 index 0000000..d04e0de --- /dev/null +++ b/priv/repo/seeds/analytics.exs @@ -0,0 +1,398 @@ +# Generates realistic analytics demo data spanning 12 months. +# +# mix run priv/repo/seeds/analytics.exs +# +# Clears existing analytics events first, then creates ~50k events with +# realistic traffic patterns, referrers, device mix, and e-commerce funnel. + +alias Berrypod.Repo +alias Berrypod.Analytics.Event + +# ── Config ── + +# How many unique "visitors" to simulate per day (base — actual varies by day) +base_daily_visitors = 40 + +# Date range: 12 months back from today +end_date = Date.utc_today() +start_date = Date.add(end_date, -364) + +# ── Reference data ── + +# Shop pages and their relative popularity (weights) +pages = [ + {"/", 30}, + {"/collections/t-shirts", 15}, + {"/collections/hoodies", 12}, + {"/collections/mugs", 10}, + {"/collections/stickers", 8}, + {"/products/1", 8}, + {"/products/2", 7}, + {"/products/3", 6}, + {"/products/4", 5}, + {"/products/5", 5}, + {"/products/6", 4}, + {"/products/7", 3}, + {"/products/8", 3}, + {"/products/9", 2}, + {"/products/10", 2}, + {"/about", 4}, + {"/delivery", 3}, + {"/contact", 2}, + {"/privacy", 1}, + {"/terms", 1}, + {"/cart", 6} +] + +# Referrer sources with weights (nil = direct traffic) +sources = [ + {nil, nil, 35}, + {"google.com", "Google", 25}, + {"instagram.com", "Instagram", 10}, + {"facebook.com", "Facebook", 8}, + {"tiktok.com", "TikTok", 6}, + {"twitter.com", "Twitter", 4}, + {"pinterest.com", "Pinterest", 4}, + {"reddit.com", "Reddit", 3}, + {"youtube.com", "YouTube", 2}, + {"bing.com", "Bing", 1}, + {"etsy.com", nil, 1}, + {"blogpost.example.com", nil, 1} +] + +# UTM campaigns (only applied to social/search traffic, not direct) +campaigns = [ + {nil, nil, nil, 60}, + {"instagram", "social", "summer_sale", 10}, + {"facebook", "cpc", "retarget_q4", 8}, + {"google", "cpc", "brand_terms", 7}, + {"tiktok", "social", "viral_hoodie", 5}, + {"newsletter", "email", "weekly_digest", 5}, + {"pinterest", "social", "pin_collection", 3}, + {"twitter", "social", "launch_promo", 2} +] + +# Countries with weights (UK-focused shop) +countries = [ + {"GB", 40}, + {"US", 15}, + {"DE", 7}, + {"FR", 5}, + {"CA", 4}, + {"AU", 4}, + {"NL", 3}, + {"IE", 3}, + {"SE", 2}, + {"IT", 2}, + {"ES", 2}, + {"BE", 2}, + {"NO", 1}, + {"DK", 1}, + {"PL", 1}, + {"CH", 1}, + {"NZ", 1}, + {"JP", 1}, + {"IN", 2}, + {"BR", 2}, + {"PT", 1} +] + +# Browsers (matches UAParser output) +browsers = [ + {"Chrome", 55}, + {"Safari", 25}, + {"Firefox", 10}, + {"Edge", 8}, + {"Opera", 2} +] + +# Operating systems (matches UAParser output) +oses = [ + {"iOS", 30}, + {"Android", 25}, + {"Windows", 22}, + {"macOS", 15}, + {"Linux", 5}, + {"ChromeOS", 3} +] + +# Screen sizes (matches classify_screen/1 in AnalyticsHook) +screen_sizes = [ + {"mobile", 55}, + {"tablet", 12}, + {"desktop", 33} +] + +# ── Helpers ── + +# Weighted random pick from a list of {item, weight} or {a, b, weight} tuples +weighted_pick = fn items -> + total = items |> Enum.map(&elem(&1, tuple_size(&1) - 1)) |> Enum.sum() + roll = :rand.uniform() * total + + Enum.reduce_while(items, 0.0, fn item, acc -> + weight = elem(item, tuple_size(item) - 1) + acc = acc + weight + if acc >= roll, do: {:halt, item}, else: {:cont, acc} + end) +end + +# Day-of-week multiplier (weekends get more traffic for a consumer shop) +day_multiplier = fn date -> + case Date.day_of_week(date) do + 6 -> 1.3 + 7 -> 1.2 + 1 -> 0.85 + _ -> 1.0 + end +end + +# Monthly growth curve — traffic grows over time (new shop ramping up) +month_multiplier = fn date -> + months_ago = Date.diff(end_date, date) / 30.0 + # Start at 0.3x and grow to 1.0x over the year + max(0.3, 1.0 - months_ago * 0.06) +end + +# Seasonal bumps (Nov-Dec holiday shopping, Jan sale) +seasonal_multiplier = fn date -> + case date.month do + 11 -> 1.4 + 12 -> 1.8 + 1 -> 1.3 + 6 -> 1.1 + 7 -> 1.1 + _ -> 1.0 + end +end + +# Random time of day (weighted toward daytime UK hours) +random_time = fn -> + # Bell curve centered around 14:00 UTC (afternoon UK time) + hour = min(23, max(0, round(:rand.normal(14.0, 4.0)))) + minute = :rand.uniform(60) - 1 + second = :rand.uniform(60) - 1 + Time.new!(hour, minute, second) +end + +# ── Clear existing data ── + +IO.puts("Clearing existing analytics events...") +{deleted, _} = Repo.delete_all(Event) +IO.puts(" Deleted #{deleted} existing events") + +# ── Generate events ── + +IO.puts("Generating analytics data from #{start_date} to #{end_date}...") + +dates = Date.range(start_date, end_date) + +all_events = + Enum.flat_map(dates, fn date -> + # Calculate visitor count for this day + base = base_daily_visitors + + multiplied = + base * day_multiplier.(date) * month_multiplier.(date) * seasonal_multiplier.(date) + + # Add some randomness (+/- 20%) + jitter = 0.8 + :rand.uniform() * 0.4 + visitor_count = round(multiplied * jitter) + + # Generate visitors for this day + Enum.flat_map(1..visitor_count, fn _v -> + visitor_hash = :crypto.strong_rand_bytes(8) + session_hash = :crypto.strong_rand_bytes(8) + + # Pick visitor attributes (consistent within a visit) + {referrer, referrer_source, _} = weighted_pick.(sources) + {country_code, _} = weighted_pick.(countries) + {browser, _} = weighted_pick.(browsers) + {os, _} = weighted_pick.(oses) + {screen_size, _} = weighted_pick.(screen_sizes) + + # Maybe assign UTM params (only if has a referrer source) + {utm_source, utm_medium, utm_campaign} = + if referrer_source do + {src, med, camp, _} = weighted_pick.(campaigns) + {src, med, camp} + else + {nil, nil, nil} + end + + # How many pages does this visitor view? (1-6, weighted toward fewer) + page_count = min(6, max(1, round(:rand.normal(2.0, 1.2)))) + + # Pick pages for this session + session_pages = + Enum.map(1..page_count, fn _ -> + {page, _} = weighted_pick.(pages) + page + end) + + # Generate pageview events with increasing timestamps + base_time = random_time.() + base_dt = DateTime.new!(date, base_time, "Etc/UTC") + + pageview_events = + session_pages + |> Enum.with_index() + |> Enum.map(fn {pathname, i} -> + # Each subsequent page is 15-120 seconds later + offset = if i == 0, do: 0, else: Enum.sum(for _ <- 1..i, do: 15 + :rand.uniform(105)) + ts = DateTime.add(base_dt, offset, :second) + + [ + id: Ecto.UUID.generate(), + name: "pageview", + pathname: pathname, + visitor_hash: visitor_hash, + session_hash: session_hash, + referrer: referrer, + referrer_source: referrer_source, + utm_source: utm_source, + utm_medium: utm_medium, + utm_campaign: utm_campaign, + country_code: country_code, + browser: browser, + os: os, + screen_size: screen_size, + revenue: nil, + inserted_at: DateTime.truncate(ts, :second) + ] + end) + + # E-commerce funnel events (progressive drop-off) + # Only trigger if visitor viewed a product page + viewed_product = Enum.any?(session_pages, &String.starts_with?(&1, "/products/")) + + ecommerce_events = + if viewed_product do + product_path = Enum.find(session_pages, &String.starts_with?(&1, "/products/")) + last_ts = pageview_events |> List.last() |> Keyword.get(:inserted_at) + + base_attrs = [ + visitor_hash: visitor_hash, + session_hash: session_hash, + referrer: referrer, + referrer_source: referrer_source, + utm_source: utm_source, + utm_medium: utm_medium, + utm_campaign: utm_campaign, + country_code: country_code, + browser: browser, + os: os, + screen_size: screen_size + ] + + # product_view: 100% of product page viewers + product_view = [ + [ + id: Ecto.UUID.generate(), + name: "product_view", + pathname: product_path, + revenue: nil, + inserted_at: DateTime.add(last_ts, 5, :second) + ] ++ base_attrs + ] + + # add_to_cart: ~35% of product viewers + add_to_cart = + if :rand.uniform() < 0.35 do + [ + [ + id: Ecto.UUID.generate(), + name: "add_to_cart", + pathname: product_path, + revenue: nil, + inserted_at: DateTime.add(last_ts, 30, :second) + ] ++ base_attrs + ] + else + [] + end + + # checkout_start: ~60% of add-to-carters + checkout_start = + if add_to_cart != [] and :rand.uniform() < 0.60 do + [ + [ + id: Ecto.UUID.generate(), + name: "checkout_start", + pathname: "/cart", + revenue: nil, + inserted_at: DateTime.add(last_ts, 60, :second) + ] ++ base_attrs + ] + else + [] + end + + # purchase: ~70% of checkout starters + purchase = + if checkout_start != [] and :rand.uniform() < 0.70 do + # Revenue between 1500 (GBP 15.00) and 8500 (GBP 85.00) + revenue = 1500 + :rand.uniform(7000) + + [ + [ + id: Ecto.UUID.generate(), + name: "purchase", + pathname: "/checkout/success", + revenue: revenue, + inserted_at: DateTime.add(last_ts, 120, :second) + ] ++ base_attrs + ] + else + [] + end + + product_view ++ add_to_cart ++ checkout_start ++ purchase + else + [] + end + + pageview_events ++ ecommerce_events + end) + end) + +# ── Batch insert ── + +total = length(all_events) +IO.puts("Inserting #{total} events in batches...") + +all_events +|> Enum.chunk_every(1000) +|> Enum.with_index(1) +|> Enum.each(fn {batch, i} -> + Repo.insert_all(Event, batch) + progress = min(i * 1000, total) + IO.write("\r #{progress}/#{total}") +end) + +IO.puts("\n Done!") + +# ── Summary stats ── + +pageviews = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "pageview" end) +product_views = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "product_view" end) +add_to_carts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "add_to_cart" end) +checkouts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "checkout_start" end) +purchases = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "purchase" end) + +total_revenue = + all_events + |> Enum.filter(fn e -> Keyword.get(e, :name) == "purchase" end) + |> Enum.map(fn e -> Keyword.get(e, :revenue) end) + |> Enum.sum() + +IO.puts(""" + +Summary: + Pageviews: #{pageviews} + Product views: #{product_views} + Add to cart: #{add_to_carts} + Checkouts: #{checkouts} + Purchases: #{purchases} + Revenue: GBP #{Float.round(total_revenue / 100, 2)} +""")