berrypod/priv/repo/seeds/analytics.exs
jamey 6eda1de1bc
All checks were successful
deploy / deploy (push) Successful in 1m21s
add period comparison deltas to analytics stat cards
Each stat card now shows the percentage change vs the equivalent
previous period (e.g. 30d compares last 30 days vs 30 days before).
Handles zero-baseline with "new" label and caps extreme deltas at
>999%. Seed data extended to 2 years for meaningful 12m comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:01:25 +00:00

399 lines
11 KiB
Elixir

# Generates realistic analytics demo data spanning 2 years.
#
# mix run priv/repo/seeds/analytics.exs
#
# Clears existing analytics events first, then creates ~90k 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: 2 years back from today
end_date = Date.utc_today()
start_date = Date.add(end_date, -729)
# ── 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.2x two years ago and grow to 1.0x now
max(0.2, 1.0 - months_ago * 0.035)
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)}
""")