diff --git a/docs/tickets/mps-3.md b/docs/tickets/mps-3.md index 53ea8d2..46d34d9 100644 --- a/docs/tickets/mps-3.md +++ b/docs/tickets/mps-3.md @@ -4,48 +4,192 @@ MPS-2 collects anonymous signals and derives Engagement, Attention, Learning, and Passive Consumption scores. Creators need somewhere to see this data and -act on it. +act on it — know which content pulls people in, which holds them, and where +traffic comes from so they know where to spend time and energy. ## Solution -Add a simple analytics page accessible from the shop dashboard. No graphs -library — clean server-rendered HTML with CSS grid tables. Machine learning -refinements to the derived scores can enhance this page later without changing -its structure. +Add a server-rendered analytics page at `/s/{shop_id}/analytics`. No JS graph +libraries — CSS grid tables, server-computed aggregates, progressive +enhancement. Machine learning can refine the derived scores later without +changing the page structure. -### Content +## Access -- **Top products by views** — ranked list, last 7 / 14 / 21 days -- **Ring entry points** — which products are "front doors" (top 7 by `is_ring_entry` count) -- **Average ring depth** — how far viewers ride the ring (mean `ring_position`) -- **Daily view totals** — last 21 days, per product and shop-wide -- **Engagement leaders** — products with highest average engagement score -- **Attention holders** — products with highest average attention score -- **Study material** — products with highest learning signal (people rewind, slow down, re-read) -- **Background favorites** — products with highest passive consumption score (lean-back plays) -- **Traffic sources** — breakdown by `referrer_class` (direct / search / social / internal) -- **Device split** — mobile vs tablet vs desktop percentages +- Route: `GET /s/{shop_id}/analytics` +- Requires `shop_editor_required` (same as shop settings) +- Link from shop settings page (near existing nav) -### Access +## Page Sections -New route `/shop/analytics` — only visible to shop owner/mods. Link from shop -settings or dashboard nav. +### 1. Overview Strip (shop-wide, last 7 days) -### Privacy +A single row of key numbers at the top: -All data is anonymous aggregates computed from `mps_page_session` rows. No -individual viewer data exists to display even if someone wanted to. +| Metric | Query | +|--------|-------| +| Total views | `COUNT(*) WHERE visible_ms >= 7000 AND created_timestamp > 7d ago` | +| Unique products viewed | `COUNT(DISTINCT product_id) WHERE visible_ms >= 7000` | +| Avg session duration | `AVG(wall_clock_ms)` | +| Avg ring depth | `AVG(ring_position) WHERE ring_position IS NOT NULL` | +| Top traffic source | `MODE(referrer_class)` (show label: direct/search/social/internal) | +| Device split | `% per device_class` shown as "42% mobile · 7% tablet · 51% desktop" | + +### 2. Top Products by Views (last 7 / 14 / 21 days) + +Ranked table, top 21 products: + +| # | Title | Views (7d) | Views (14d) | Views (21d) | Trend | +|---|-------|-----------|------------|------------|-------| + +"Trend" = simple arrow: views(7d) > views(14d)/2 → rising, else falling. +Each title links to the product page. + +Query: `GROUP BY product_id`, `COUNT(*) WHERE visible_ms >= 7000`, partitioned +by time windows using `created_timestamp`. + +### 3. Ring Entry Points (top 7 front doors) + +Which products do people land on first? + +| # | Title | Ring Entries (21d) | % of All Entries | +|---|-------|--------------------|-----------------| + +Query: `COUNT(*) WHERE is_ring_entry = true GROUP BY product_id ORDER BY +count DESC LIMIT 7`. + +This tells the creator: "People find your shop through these 7 products — make +sure they're polished." + +### 4. Engagement & Attention Leaders (top 7 each) + +Two side-by-side tables: + +**Engagement Leaders** (highest lean-in): + +| # | Title | Avg Engagement | Sessions | +|---|-------|---------------|----------| + +`engagement = active_ms / wall_clock_ms` — computed per session, averaged per +product. Only include sessions with `wall_clock_ms >= 7000` (real visits). + +**Attention Holders** (highest focused presence): + +| # | Title | Avg Attention | Sessions | +|---|-------|--------------|----------| + +`attention = visible_ms / wall_clock_ms` — same filtering. + +### 5. Study Material vs Background Favorites (top 7 each) + +Two side-by-side tables: + +**Study Material** (people rewind, slow down, re-read): + +| # | Title | Learning Score | Sessions | +|---|-------|---------------|----------| + +Learning score per session = count of true indicators: +- `media_seek_back_count > 0` +- `media_speed < 1.0` (and not NULL) +- `scroll_direction_changes > 3` +- `media_pause_count > 2` +- `active_ms / wall_clock_ms > 0.7` + +Average per product, ranked. Minimum 7 sessions to qualify. + +**Background Favorites** (lean-back plays): + +| # | Title | Passive Score | Sessions | +|---|-------|--------------|----------| + +Passive score per session = count of true indicators: +- `visible_ms / wall_clock_ms > 0.7` +- `active_ms / wall_clock_ms < 0.3` +- `media_percent_played > 0.69` +- `media_play_count = 1` +- `media_pause_count = 0` + +Average per product, ranked. Minimum 7 sessions to qualify. + +### 6. Traffic Sources (last 21 days) + +Simple breakdown table: + +| Source | Sessions | % | +|--------|----------|---| +| Direct | 142 | 42% | +| Search | 69 | 21% | +| Social | 47 | 14% | +| Internal | 70 | 21% | +| Unknown | 7 | 2% | + +Query: `COUNT(*) GROUP BY referrer_class`. + +### 7. Device Split (last 21 days) + +| Device | Sessions | % | +|--------|----------|---| +| Mobile | 210 | 42% | +| Tablet | 42 | 8% | +| Desktop | 252 | 50% | + +Query: `COUNT(*) GROUP BY device_class`. + +## Query Strategy + +All queries run against `mps_page_session` with time filters using +`created_timestamp`. Since timestamps are milliseconds: + +```python +cutoff_7d = now_timestamp() - (7 * 24 * 60 * 60 * 1000) +cutoff_14d = now_timestamp() - (14 * 24 * 60 * 60 * 1000) +cutoff_21d = now_timestamp() - (21 * 24 * 60 * 60 * 1000) +``` + +For the engagement/attention/learning/passive scores, compute per-session in +the SQL query using CASE expressions, then AVG per product. SQLite handles +this fine for shops with < 100K sessions. + +For larger shops (future), the 90-day retention + daily rollup from MPS-2 +provides pre-aggregated data. + +## Template Layout + +CSS grid, two-column on desktop, single-column on mobile. No flexbox per +project rules. + +``` +[Overview Strip — full width] +[Top Products — full width] +[Ring Entry Points — full width] +[Engagement Leaders | Attention Holders — side by side] +[Study Material | Background Favorites — side by side] +[Traffic Sources | Device Split — side by side] +``` + +Tables use `` with `class="analytics-table"`. No zebra striping — +keep it clean. Product titles are links. Numbers right-aligned. + +## Privacy Note + +Displayed in a small footer on the page: + +> All data is anonymous. No individual viewer can be identified. Counts +> represent aggregate sessions, not people. ## Files Changed | File | Change | |------|--------| | `views/shop.py` | New `analytics` view with aggregate queries | -| `templates/analytics.j2` | New template | -| `static/css/common.css` | Analytics table styles | -| `templates/snippets/shop_nav.j2` | Link to analytics (if exists) | -| `tests/test_functional.py` | Analytics page access tests | +| `routes.py` | New route `shop_analytics` | +| `templates/analytics.j2` | New template with 7 sections | +| `static/css/common.css` | `.analytics-table`, `.analytics-overview` styles | +| `templates/shop_settings.j2` | Link to analytics page | +| `tests/test_functional.py` | Analytics page access + permission tests | +| `tests/test_models.py` | Score computation unit tests | ## Depends On -MPS-2 (signal gathering + `mps_page_session` table) +MPS-2 (signal gathering + `mps_page_session` table + `view_count` column) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 5234088..02208b0 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -122,6 +122,7 @@ def includeme(config): config.add_route("shop_sales", "/s/{shop_id}/sales") config.add_route("shop_comments", "/s/{shop_id}/comments") + config.add_route("shop_analytics", "/s/{shop_id}/analytics") config.add_route("shop_settings", "/s/{shop_id}/settings") config.add_route( diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index b755c13..0a356d3 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -3177,3 +3177,117 @@ textarea { gap: 32px; } } + +/* Analytics Dashboard */ +.analytics-page { + max-width: 1000px; + margin-left: auto; + margin-right: auto; + padding: 0 10px; +} + +.analytics-overview { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 30px; +} + +.analytics-overview-item { + display: grid; + gap: 2px; + text-align: center; + padding: 10px; +} + +.analytics-overview-value { + font-size: 1.4em; + font-weight: bold; + color: var(--text-primary); +} + +.analytics-overview-label { + font-size: 0.8em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.analytics-table-wrap { + overflow-x: auto; + margin-bottom: 30px; +} + +.analytics-table { + width: 100%; + border-collapse: collapse; +} + +.analytics-table th { + font-size: 0.8em; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border-bottom: 2px solid var(--border-color, #dee2e6); + padding: 6px 8px; +} + +.analytics-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-light, #e9ecef); +} + +.analytics-table tbody tr:last-child td { + border-bottom: none; +} + +.analytics-num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.analytics-trend-up { + color: var(--green-color, #a3c765); +} + +.analytics-trend-down { + color: var(--text-muted, #999); +} + +.analytics-hint { + font-size: 0.85em; + color: var(--text-muted); + margin-top: 0; + margin-bottom: 10px; +} + +.analytics-pair { + display: grid; + grid-template-columns: 1fr; + gap: 30px; + margin-bottom: 30px; +} + +.analytics-privacy { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-light, #e9ecef); +} + +/* Override default table zebra striping inside analytics tables */ +.analytics-table tr:nth-child(even), +.analytics-table tr:nth-child(odd) { + background-color: transparent; +} + +@media (min-width: 960px) { + .analytics-overview { + grid-template-columns: repeat(3, 1fr); + } + + .analytics-pair { + grid-template-columns: 1fr 1fr; + } +} diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2 index 2f816c7..8d53372 100644 --- a/make_post_sell/templates/actions_view.j2 +++ b/make_post_sell/templates/actions_view.j2 @@ -40,6 +40,10 @@
💬   Shop Comments +
+
+ 📊   Shop Analytics +

⚙   Shop Settings diff --git a/make_post_sell/templates/analytics.j2 b/make_post_sell/templates/analytics.j2 new file mode 100644 index 0000000..0a12ce7 --- /dev/null +++ b/make_post_sell/templates/analytics.j2 @@ -0,0 +1,281 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + Analytics - {{ request.shop.name }} +{%- endblock append_to_head_tag_section -%} + +{% block content -%} + +
+ +

Analytics

+ + {% if overview.total_views == 0 %} +

No view data yet. Analytics appear after visitors spend at least 7 seconds on your content.

+ {% else %} + + {# --- Section 1: Overview Strip (last 7 days) --- #} +
+
+ {{ overview.total_views }} + Views (7d) +
+
+ {{ overview.unique_products }} + Products Viewed +
+
+ {{ overview.avg_session }} + Avg Session +
+
+ {{ overview.avg_ring_depth }} + Avg Ring Depth +
+
+ {{ overview.top_source }} + Top Source +
+
+ {{ overview.device_split }} + Devices (7d) +
+
+ + {# --- Section 2: Top Products by Views --- #} + {% if top_products %} +

Top Products by Views

+
+
+ + + + + + + + + + + + {% for p in top_products %} + + + + + + + + + {% endfor %} + +
#Title7d14d21dTrend
{{ p.rank }}{{ p.title }}{{ p.views_7d }}{{ p.views_14d }}{{ p.views_21d }}{% if p.trend == "rising" %}{% else %}{% endif %}
+ + {% endif %} + + {# --- Section 3: Ring Entry Points --- #} + {% if ring_entries %} +

Ring Entry Points (top 7 front doors, last 21 days)

+

People find your shop through these products — make sure they're polished.

+
+ + + + + + + + + + + {% for r in ring_entries %} + + + + + + + {% endfor %} + +
#TitleEntries% of All
{{ r.rank }}{{ r.title }}{{ r.count }}{{ r.pct }}
+
+ {% endif %} + + {# --- Sections 4 & 5: Engagement & Attention (side by side) --- #} + {% if engagement or attention %} +
+ {% if engagement %} +
+

Engagement Leaders

+

Highest lean-in: active interaction vs wall clock.

+ + + + + + + + + + + {% for e in engagement %} + + + + + + + {% endfor %} + +
#TitleAvgSessions
{{ e.rank }}{{ e.title }}{{ e.score }}{{ e.sessions }}
+
+ {% endif %} + + {% if attention %} +
+

Attention Holders

+

Highest focused presence: visible time vs wall clock.

+ + + + + + + + + + + {% for a in attention %} + + + + + + + {% endfor %} + +
#TitleAvgSessions
{{ a.rank }}{{ a.title }}{{ a.score }}{{ a.sessions }}
+
+ {% endif %} +
+ {% endif %} + + {# --- Sections 6 & 7: Study Material & Background Favorites (side by side) --- #} + {% if learning or passive %} +
+ {% if learning %} +
+

Study Material

+

People rewind, slow down, re-read. Score out of 5.

+ + + + + + + + + + + {% for l in learning %} + + + + + + + {% endfor %} + +
#TitleScoreSessions
{{ l.rank }}{{ l.title }}{{ l.score }}{{ l.sessions }}
+
+ {% endif %} + + {% if passive %} +
+

Background Favorites

+

Lean-back plays: visible but hands-off. Score out of 5.

+ + + + + + + + + + + {% for b in passive %} + + + + + + + {% endfor %} + +
#TitleScoreSessions
{{ b.rank }}{{ b.title }}{{ b.score }}{{ b.sessions }}
+
+ {% endif %} +
+ {% endif %} + + {# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #} + {% if traffic or devices %} +
+ {% if traffic %} +
+

Traffic Sources (last 21 days)

+ + + + + + + + + + {% for t in traffic %} + + + + + + {% endfor %} + +
SourceSessions%
{{ t.source }}{{ t.count }}{{ t.pct }}
+
+ {% endif %} + + {% if devices %} +
+

Device Split (last 21 days)

+ + + + + + + + + + {% for d in devices %} + + + + + + {% endfor %} + +
DeviceSessions%
{{ d.device }}{{ d.count }}{{ d.pct }}
+
+ {% endif %} +
+ {% endif %} + + {% endif %}{# end overview.total_views check #} + +

All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.

+ + + +{%- endblock -%} diff --git a/make_post_sell/tests/conftest.py b/make_post_sell/tests/conftest.py index 568f059..12be284 100644 --- a/make_post_sell/tests/conftest.py +++ b/make_post_sell/tests/conftest.py @@ -16,7 +16,7 @@ def pytest_configure(config): SQLite locking conflicts. WAL mode is enabled for better concurrency. """ # Get worker ID (e.g., "gw0", "gw1", etc.) for pytest-xdist - worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master") + worker_id = os.environ.get("PYTEST_XDIST_WORKER", f"pid{os.getpid()}") # Set unique database path for this worker test_db_path = f"test_make_post_sell_{worker_id}.sqlite" @@ -30,11 +30,13 @@ def pytest_unconfigure(config): """Clean up test database after all tests complete.""" if hasattr(config, "test_db_path"): db_path = config.test_db_path - if os.path.exists(db_path): - try: - os.remove(db_path) - except Exception as e: - print(f"Warning: Could not remove test database {db_path}: {e}") + for suffix in ("", "-wal", "-shm"): + path = db_path + suffix + if os.path.exists(path): + try: + os.remove(path) + except Exception as e: + print(f"Warning: Could not remove {path}: {e}") @pytest.fixture(scope="session") @@ -48,7 +50,7 @@ def db_engine(request): from sqlalchemy import create_engine, event # Use worker-specific database - worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master") + worker_id = os.environ.get("PYTEST_XDIST_WORKER", f"pid{os.getpid()}") db_path = f"test_make_post_sell_{worker_id}.sqlite" db_url = f"sqlite:///{db_path}" diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index ecf93b1..3e62bae 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -3413,3 +3413,83 @@ class TestBeacon(AuthenticatedFunctionalTests): .count() ) self.assertEqual(count2, 1) + + +class TestAnalytics(AuthenticatedFunctionalTests): + """Functional tests for the /s/{shop_id}/analytics page.""" + + def test_analytics_requires_editor(self): + """Unauthenticated users get redirected from analytics.""" + shop = self._create_shop_helper() + shop_id = str(shop.id) + self.testapp.get("/log-out") + res = self.testapp.get(f"/s/{shop_id}/analytics", status=302) + self.assertEqual(res.status_int, 302) + + def test_analytics_accessible_by_owner(self): + """Shop owner can access the analytics page.""" + shop = self._create_shop_helper() + shop_id = str(shop.id) + res = self.testapp.get(f"/s/{shop_id}/analytics", status=200) + self.assertIn("Analytics", res.body.decode()) + + def test_analytics_empty_state(self): + """Analytics page shows empty state message when no data exists.""" + shop = self._create_shop_helper() + shop_id = str(shop.id) + res = self.testapp.get(f"/s/{shop_id}/analytics", status=200) + body = res.body.decode() + self.assertIn("No view data yet", body) + + def test_analytics_shows_overview_with_data(self): + """Analytics page shows overview strip when session data exists.""" + shop, product = self._setup_shop_and_product() + shop_id = str(shop.id) + + # Insert a session via beacon with visible_ms >= 7000 + self.testapp.post_json( + "/signals/beacon", + { + "product_id": str(product.id), + "shop_id": shop_id, + "session_token": "analytics_test_1", + "wall_clock_ms": 15000, + "visible_ms": 12000, + "active_ms": 8000, + "viewport_width": 1920, + }, + status=204, + ) + + res = self.testapp.get(f"/s/{shop_id}/analytics", status=200) + body = res.body.decode() + # Should show overview, not empty state + self.assertNotIn("No view data yet", body) + self.assertIn("Views (7d)", body) + + def test_analytics_privacy_note(self): + """Analytics page always shows the privacy note.""" + shop = self._create_shop_helper() + shop_id = str(shop.id) + res = self.testapp.get(f"/s/{shop_id}/analytics", status=200) + body = res.body.decode() + self.assertIn("anonymous", body) + + def _setup_shop_and_product(self): + """Create a shop and a content product, return (shop, product).""" + shop = self._create_shop_helper() + + res = self.testapp.post( + "/c/new", + { + "title": "Analytics Test Content", + "description": "For testing analytics", + "submit": True, + }, + ) + if res.status_int == 302: + res.follow() + + products = get_all_products(self.dbsession).all() + product = products[0] + return shop, product diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 3cabe35..60cc6ff 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -3185,3 +3185,74 @@ class TestSignalClassifiers(unittest.TestCase): def test_device_none(self): self.assertIsNone(classify_device(None)) + + +class TestAnalyticsHelpers(unittest.TestCase): + """Unit tests for analytics view helper functions.""" + + def test_fmt_ms_none(self): + from ..views.analytics import _fmt_ms + self.assertEqual(_fmt_ms(None), "\u2014") + + def test_fmt_ms_seconds(self): + from ..views.analytics import _fmt_ms + self.assertEqual(_fmt_ms(42000), "42s") + + def test_fmt_ms_minutes(self): + from ..views.analytics import _fmt_ms + self.assertEqual(_fmt_ms(125000), "2m 5s") + + def test_fmt_ms_hours(self): + from ..views.analytics import _fmt_ms + self.assertEqual(_fmt_ms(3661000), "1h 1m") + + def test_fmt_ms_zero(self): + from ..views.analytics import _fmt_ms + self.assertEqual(_fmt_ms(0), "0s") + + def test_fmt_pct_none(self): + from ..views.analytics import _fmt_pct + self.assertEqual(_fmt_pct(None), "\u2014") + + def test_fmt_pct_half(self): + from ..views.analytics import _fmt_pct + self.assertEqual(_fmt_pct(0.5), "50%") + + def test_fmt_pct_full(self): + from ..views.analytics import _fmt_pct + self.assertEqual(_fmt_pct(1.0), "100%") + + def test_fmt_pct_zero(self): + from ..views.analytics import _fmt_pct + self.assertEqual(_fmt_pct(0.0), "0%") + + def test_fmt_score_none(self): + from ..views.analytics import _fmt_score + self.assertEqual(_fmt_score(None), "\u2014") + + def test_fmt_score_value(self): + from ..views.analytics import _fmt_score + self.assertEqual(_fmt_score(3.14159), "3.1") + + def test_fmt_score_zero(self): + from ..views.analytics import _fmt_score + self.assertEqual(_fmt_score(0.0), "0.0") + + def test_cutoffs_are_7_14_21_days_apart(self): + from ..views.analytics import _cutoffs + c7, c14, c21 = _cutoffs() + day_ms = 24 * 60 * 60 * 1000 + self.assertAlmostEqual(c7 - c14, 7 * day_ms, delta=1000) + self.assertAlmostEqual(c14 - c21, 7 * day_ms, delta=1000) + + def test_referrer_labels_complete(self): + from ..views.analytics import REFERRER_LABELS + self.assertEqual(len(REFERRER_LABELS), 5) + self.assertIn(0, REFERRER_LABELS) + self.assertIn(4, REFERRER_LABELS) + + def test_device_labels_complete(self): + from ..views.analytics import DEVICE_LABELS + self.assertEqual(len(DEVICE_LABELS), 3) + self.assertEqual(DEVICE_LABELS[0], "Mobile") + self.assertEqual(DEVICE_LABELS[2], "Desktop") diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py new file mode 100644 index 0000000..f36bcd4 --- /dev/null +++ b/make_post_sell/views/analytics.py @@ -0,0 +1,353 @@ +from pyramid.view import view_config +from sqlalchemy import func, case, and_, cast, Float + +from . import shop_editor_required +from ..models.page_session import PageSession +from ..models.product import Product +from ..models.meta import now_timestamp + + +REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"} +DEVICE_LABELS = {0: "Mobile", 1: "Tablet", 2: "Desktop"} +MIN_SESSIONS = 7 + + +def _cutoffs(): + """Return 7d, 14d, 21d cutoff timestamps in milliseconds.""" + now = now_timestamp() + day_ms = 24 * 60 * 60 * 1000 + return now - 7 * day_ms, now - 14 * day_ms, now - 21 * day_ms + + +def _product_titles(dbsession, product_ids): + """Batch-fetch product titles and URLs for a set of product IDs.""" + if not product_ids: + return {} + products = ( + dbsession.query(Product) + .filter(Product.id.in_(product_ids)) + .all() + ) + return { + p.id: { + "title": p.title, + "url": f"/{'p' if p.is_sellable else 'c'}/{p.id}/{p.slug}", + } + for p in products + } + + +def _fmt_ms(ms): + """Format milliseconds as human-readable duration.""" + if ms is None: + return "\u2014" + s = int(ms / 1000) + if s < 60: + return f"{s}s" + m, s = divmod(s, 60) + if m < 60: + return f"{m}m {s}s" + h, m = divmod(m, 60) + return f"{h}h {m}m" + + +def _fmt_pct(val): + """Format a 0-1 float as a percentage string.""" + if val is None: + return "\u2014" + return f"{val * 100:.0f}%" + + +def _fmt_score(val): + """Format a 0-5 score with one decimal place.""" + if val is None: + return "\u2014" + return f"{val:.1f}" + + +@view_config(route_name="shop_analytics", renderer="analytics.j2") +@shop_editor_required() +def shop_analytics(request): + shop = request.shop + db = request.dbsession + cutoff_7d, cutoff_14d, cutoff_21d = _cutoffs() + + def _view_filter(cutoff): + """Sessions that count as views (7s visible) after cutoff.""" + return and_( + PageSession.shop_id == shop.id, + PageSession.visible_ms >= 7000, + PageSession.created_timestamp > cutoff, + ) + + def _session_filter(cutoff): + """Sessions with real engagement (7s wall clock) after cutoff.""" + return and_( + PageSession.shop_id == shop.id, + PageSession.wall_clock_ms >= 7000, + PageSession.created_timestamp > cutoff, + ) + + # --- Section 1: Overview Strip (last 7 days) --- + + ov = db.query( + func.count().label("total_views"), + func.count(func.distinct(PageSession.product_id)).label("unique_products"), + func.avg(PageSession.wall_clock_ms).label("avg_session_ms"), + ).filter(_view_filter(cutoff_7d)).one() + + avg_ring_depth = db.query(func.avg(PageSession.ring_position)).filter( + PageSession.shop_id == shop.id, + PageSession.ring_position.isnot(None), + PageSession.created_timestamp > cutoff_7d, + ).scalar() + + top_src = db.query( + PageSession.referrer_class, func.count().label("cnt"), + ).filter( + _view_filter(cutoff_7d), + ).group_by( + PageSession.referrer_class, + ).order_by(func.count().desc()).first() + + dev_rows_7d = db.query( + PageSession.device_class, func.count().label("cnt"), + ).filter(_view_filter(cutoff_7d)).group_by(PageSession.device_class).all() + dev_total_7d = sum(r.cnt for r in dev_rows_7d) or 1 + dev_split_str = " \u00b7 ".join( + f"{_fmt_pct(r.cnt / dev_total_7d)} {DEVICE_LABELS.get(r.device_class, '?')}" + for r in sorted(dev_rows_7d, key=lambda r: -(r.cnt or 0)) + ) or "\u2014" + + overview = { + "total_views": ov.total_views or 0, + "unique_products": ov.unique_products or 0, + "avg_session": _fmt_ms(ov.avg_session_ms), + "avg_ring_depth": f"{avg_ring_depth:.1f}" if avg_ring_depth else "\u2014", + "top_source": REFERRER_LABELS.get(top_src[0], "\u2014") if top_src else "\u2014", + "device_split": dev_split_str, + } + + # --- Section 2: Top Products by Views (7d / 14d / 21d) --- + + views_7d_expr = func.sum(case( + (PageSession.created_timestamp > cutoff_7d, 1), else_=0, + )) + views_14d_expr = func.sum(case( + (PageSession.created_timestamp > cutoff_14d, 1), else_=0, + )) + + top_prods_raw = db.query( + PageSession.product_id, + views_7d_expr.label("views_7d"), + views_14d_expr.label("views_14d"), + func.count().label("views_21d"), + ).filter( + _view_filter(cutoff_21d), + ).group_by( + PageSession.product_id, + ).order_by(views_7d_expr.desc()).limit(21).all() + + # --- Section 3: Ring Entry Points (top 7 front doors, 21d) --- + + ring_raw = db.query( + PageSession.product_id, func.count().label("cnt"), + ).filter( + PageSession.shop_id == shop.id, + PageSession.is_ring_entry == True, # noqa: E712 + PageSession.created_timestamp > cutoff_21d, + ).group_by(PageSession.product_id).order_by(func.count().desc()).limit(7).all() + + ring_total = db.query(func.count()).filter( + PageSession.shop_id == shop.id, + PageSession.is_ring_entry == True, # noqa: E712 + PageSession.created_timestamp > cutoff_21d, + ).scalar() or 1 + + # --- Section 4: Engagement Leaders (active_ms / wall_clock_ms) --- + + eng_expr = func.avg( + cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0) + ) + eng_raw = db.query( + PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"), + ).filter( + _session_filter(cutoff_21d), + ).group_by( + PageSession.product_id, + ).having(func.count() >= MIN_SESSIONS).order_by(eng_expr.desc()).limit(7).all() + + # --- Section 5: Attention Holders (visible_ms / wall_clock_ms) --- + + att_expr = func.avg( + cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0) + ) + att_raw = db.query( + PageSession.product_id, att_expr.label("score"), func.count().label("sessions"), + ).filter( + _session_filter(cutoff_21d), + ).group_by( + PageSession.product_id, + ).having(func.count() >= MIN_SESSIONS).order_by(att_expr.desc()).limit(7).all() + + # --- Section 6: Study Material (learning score: rewind, slow, re-read) --- + + learn_expr = ( + case((PageSession.media_seek_back_count > 0, 1), else_=0) + + case( + (and_(PageSession.media_speed.isnot(None), PageSession.media_speed < 1.0), 1), + else_=0, + ) + + case((PageSession.scroll_direction_changes > 3, 1), else_=0) + + case((PageSession.media_pause_count > 2, 1), else_=0) + + case( + ( + and_( + PageSession.wall_clock_ms > 0, + cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms > 0.7, + ), + 1, + ), + else_=0, + ) + ) + learn_avg = func.avg(learn_expr) + learn_raw = db.query( + PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"), + ).filter( + _session_filter(cutoff_21d), + ).group_by( + PageSession.product_id, + ).having(func.count() >= MIN_SESSIONS).order_by(learn_avg.desc()).limit(7).all() + + # --- Section 7: Background Favorites (passive score: lean-back plays) --- + + pass_expr = ( + case( + ( + and_( + PageSession.wall_clock_ms > 0, + cast(PageSession.visible_ms, Float) / PageSession.wall_clock_ms > 0.7, + ), + 1, + ), + else_=0, + ) + + case( + ( + and_( + PageSession.wall_clock_ms > 0, + cast(PageSession.active_ms, Float) / PageSession.wall_clock_ms < 0.3, + ), + 1, + ), + else_=0, + ) + + case((PageSession.media_percent_played > 0.69, 1), else_=0) + + case((PageSession.media_play_count == 1, 1), else_=0) + + case((PageSession.media_pause_count == 0, 1), else_=0) + ) + pass_avg = func.avg(pass_expr) + pass_raw = db.query( + PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"), + ).filter( + _session_filter(cutoff_21d), + ).group_by( + PageSession.product_id, + ).having(func.count() >= MIN_SESSIONS).order_by(pass_avg.desc()).limit(7).all() + + # --- Section 8: Traffic Sources (21d) --- + + traffic_raw = db.query( + PageSession.referrer_class, func.count().label("cnt"), + ).filter( + _view_filter(cutoff_21d), + ).group_by(PageSession.referrer_class).order_by(func.count().desc()).all() + traffic_total = sum(r.cnt for r in traffic_raw) or 1 + + # --- Section 9: Device Split (21d) --- + + device_raw = db.query( + PageSession.device_class, func.count().label("cnt"), + ).filter( + _view_filter(cutoff_21d), + ).group_by(PageSession.device_class).order_by(func.count().desc()).all() + device_total = sum(r.cnt for r in device_raw) or 1 + + # --- Resolve all product titles in one batch --- + + all_pids = set() + for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw): + for r in rows: + all_pids.add(r.product_id) + titles = _product_titles(db, list(all_pids)) + + def _t(pid): + return titles.get(pid, {}).get("title", "Unknown") + + def _u(pid): + return titles.get(pid, {}).get("url", "#") + + # --- Build template-ready data --- + + top_products = [] + for i, r in enumerate(top_prods_raw): + v7, v14 = r.views_7d or 0, r.views_14d or 0 + top_products.append({ + "rank": i + 1, + "title": _t(r.product_id), + "url": _u(r.product_id), + "views_7d": v7, + "views_14d": v14, + "views_21d": r.views_21d or 0, + "trend": "rising" if v14 > 0 and v7 > v14 / 2 else "falling", + }) + + ring_entries = [ + { + "rank": i + 1, + "title": _t(r.product_id), + "url": _u(r.product_id), + "count": r.cnt, + "pct": _fmt_pct(r.cnt / ring_total), + } + for i, r in enumerate(ring_raw) + ] + + def _ranked(raw, score_fmt): + return [ + { + "rank": i + 1, + "title": _t(r.product_id), + "url": _u(r.product_id), + "score": score_fmt(r.score), + "sessions": r.sessions, + } + for i, r in enumerate(raw) + ] + + return { + "overview": overview, + "top_products": top_products, + "ring_entries": ring_entries, + "engagement": _ranked(eng_raw, _fmt_pct), + "attention": _ranked(att_raw, _fmt_pct), + "learning": _ranked(learn_raw, _fmt_score), + "passive": _ranked(pass_raw, _fmt_score), + "traffic": [ + { + "source": REFERRER_LABELS.get(r.referrer_class, "Unknown"), + "count": r.cnt, + "pct": _fmt_pct(r.cnt / traffic_total), + } + for r in traffic_raw + ], + "devices": [ + { + "device": DEVICE_LABELS.get(r.device_class, "Unknown"), + "count": r.cnt, + "pct": _fmt_pct(r.cnt / device_total), + } + for r in device_raw + ], + } diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 9652798..8f94d69 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -359,6 +359,7 @@ def product_edit(request): reforge_discovery_ring_async( product.shop.id, request.registry["dbsession_factory"] ) + return HTTPFound(product.absolute_edit_url(request)) signed_posts = {}