add analytics v2 plan, demo seed data, and improved funnel display
- analytics-v2 plan with prioritised improvements (comparison mode, filtering, CSV export, entry/exit pages) - seed script generating ~35k realistic events over 12 months (weighted traffic, referrers, devices, e-commerce funnel) - funnel chart now shows overall conversion rate from product views instead of step-to-step percentages - summary line with overall conversion rate and revenue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f91b47f0c3
commit
65e646a7eb
@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
Ordered by dependency level — admin shell chain first (unblocks most downstream work).
|
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 |
|
| # | Task | Depends on | Est | Status |
|
||||||
|---|------|------------|-----|--------|
|
|---|------|------------|-----|--------|
|
||||||
|
|||||||
50
docs/plans/analytics-v2.md
Normal file
50
docs/plans/analytics-v2.md
Normal file
@ -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
|
||||||
@ -361,20 +361,24 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
{"Purchase", assigns.funnel.purchases}
|
{"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_with_rates =
|
||||||
steps
|
steps
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.map(fn {{label, count}, i} ->
|
|> Enum.map(fn {{label, count}, i} ->
|
||||||
prev_count = if i > 0, do: elem(Enum.at(steps, i - 1), 1), else: count
|
overall_rate = if top_count > 0, do: Float.round(count / top_count * 100, 1), else: 0.0
|
||||||
rate = if prev_count > 0, do: round(count / prev_count * 100), else: 0
|
width_pct = if top_count > 0, do: max(count / top_count * 100, 5), else: 5
|
||||||
width_pct = if max_val > 0, do: max(count / max_val * 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)
|
end)
|
||||||
|
|
||||||
assigns = assign(assigns, steps: steps_with_rates)
|
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
@ -390,18 +394,24 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
</div>
|
</div>
|
||||||
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
|
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
|
||||||
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
|
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
|
||||||
{step.count}
|
{format_number(step.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
:if={step.index > 0}
|
:if={step.index > 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}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@revenue > 0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;">
|
<div style="margin-top: 0.75rem; font-size: 0.875rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
Revenue: {Cart.format_price(@revenue)}
|
<span style="font-weight: 600;">{@conversion_rate}% overall conversion</span>
|
||||||
|
<span
|
||||||
|
:if={@revenue > 0}
|
||||||
|
style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
|
||||||
|
>
|
||||||
|
· Revenue: {Cart.format_price(@revenue)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
398
priv/repo/seeds/analytics.exs
Normal file
398
priv/repo/seeds/analytics.exs
Normal file
@ -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)}
|
||||||
|
""")
|
||||||
Loading…
Reference in New Issue
Block a user