diff --git a/growth-agent/.env.example b/growth-agent/.env.example index 5685c364d..06a8918ba 100644 --- a/growth-agent/.env.example +++ b/growth-agent/.env.example @@ -2,10 +2,6 @@ # Copy to .env and fill in values for local debugging and runtime-related values. # Deploy context comes from your active Scaleway profile in ~/.config/scw/config.yaml. -# Umami Cloud API -UMAMI_API_KEY=your-umami-api-key -UMAMI_WEBSITE_ID=e41ae7d9-a536-426d-b40e-f2488b11bf95 - # LLM provider — "ionos" (default) or "mistral" LLM_PROVIDER=ionos # Optional: override the provider's default model (leave blank to use default) @@ -17,7 +13,8 @@ IONOS_API_TOKEN=your-ionos-api-token # Mistral API (used when LLM_PROVIDER=mistral) MISTRAL_API_KEY=your-mistral-api-key -# Scaleway S3 +# Scaleway S3 — also powers _collect_page_traffic's read of the analytics +# service's rollups (rollup/{site}/{month}.json at the bucket root) SCW_ACCESS_KEY=your-scw-access-key SCW_SECRET_KEY=your-scw-secret-key S3_BUCKET=my-imagestore diff --git a/growth-agent/GROWTH_AGENT.md b/growth-agent/GROWTH_AGENT.md index 8852717d2..e09cc9cc7 100644 --- a/growth-agent/GROWTH_AGENT.md +++ b/growth-agent/GROWTH_AGENT.md @@ -2,6 +2,13 @@ ## Implementation Plan & Architecture +> **Note:** this plan predates the migration off Umami Cloud. Umami has been +> fully replaced by the self-hosted `analytics` service (see +> `../analytics/README.md`); every reference to Umami below is historical. +> Page traffic is now collected via `_collect_page_traffic` in +> `agent/nodes/ingest.py`, reading the `analytics` service's own S3 rollups — +> `agent/umami_client.py` no longer exists. + --- # 1. Objective @@ -177,7 +184,6 @@ growth-agent/ │ ├── __init__.py │ ├── models.py # Pydantic state models (Insights, Strategy, Draft, LLMAnalysis, PageMeta, ...) │ ├── llm_client.py # IONOS LLM client (langchain-openai ChatOpenAI) -│ ├── umami_client.py # Umami Cloud REST API client │ ├── page_meta.py # HTTP-based page metadata fetcher (title, description from meta tags) │ ├── storage.py # LocalStorage (notebooks) + S3Storage (production) + load_model helper │ ├── publisher.py # Publish approved drafts to platforms @@ -186,7 +192,7 @@ growth-agent/ │ │ └── bluesky.py # AT Protocol client (httpx) │ ├── nodes/ # LangGraph node modules │ │ ├── __init__.py -│ │ ├── ingest.py # ingest_analytics() — Umami + social metrics +│ │ ├── ingest.py # ingest_analytics() — page traffic + social metrics │ │ ├── insights.py # generate_insights() — LLM analysis + prompt templates │ │ ├── strategy.py # adjust_strategy() — LLM strategy adjustments + audit log │ │ ├── plan.py # create_plan() — page selection, scheduling, pipeline depth @@ -196,7 +202,7 @@ growth-agent/ │ └── graph.py # LangGraph StateGraph — OODA loop compilation │ ├── notebooks/ -│ ├── 01_umami_ingest.ipynb +│ ├── 01_analytics_ingest.ipynb │ ├── 02_llm_insights.ipynb │ ├── 03_content_creation.ipynb │ ├── 04_s3_state.ipynb diff --git a/growth-agent/agent/models.py b/growth-agent/agent/models.py index 556c0e69a..fdacaaf6d 100644 --- a/growth-agent/agent/models.py +++ b/growth-agent/agent/models.py @@ -151,6 +151,9 @@ class Performance(BaseModel): """Aggregated engagement metrics for all published posts.""" posts: list[PostMetrics] = Field(default_factory=list) + # Page path -> hits in the trailing 30 days, from the analytics service's + # monthly rollups. See agent/nodes/ingest.py's _collect_page_traffic. + page_traffic: dict[str, int] = Field(default_factory=dict) class DraftCritique(BaseModel): diff --git a/growth-agent/agent/nodes/ingest.py b/growth-agent/agent/nodes/ingest.py index 2e1e5bb1f..955b85719 100644 --- a/growth-agent/agent/nodes/ingest.py +++ b/growth-agent/agent/nodes/ingest.py @@ -2,7 +2,8 @@ import logging import os -from datetime import datetime, timedelta, timezone +from collections import defaultdict +from datetime import date, datetime, timedelta, timezone from agent.models import ( ContentQueue, @@ -14,7 +15,7 @@ from agent.platforms.bluesky import BlueskyClient from agent.platforms.mastodon import MastodonClient from agent.state import AgentState -from agent.storage import load_model +from agent.storage import S3Storage, load_model logger = logging.getLogger("growth-agent") @@ -65,6 +66,12 @@ def ingest_analytics(storage) -> Insights: # Per-post engagement metrics _collect_post_metrics(storage) + # Page traffic (last 30 days). _collect_post_metrics's write now carries its + # own page_traffic forward, so this ordering is defense-in-depth rather than + # load-bearing — but keeping it after post metrics still means this always + # merges onto the freshest performance.json. + _collect_page_traffic(storage) + return insights @@ -155,7 +162,76 @@ def _collect_post_metrics(storage) -> None: logger.exception("Bluesky per-post metrics failed") refreshed = len(recent_mastodon) + len(recent_bluesky) - storage.write("performance.json", Performance(posts=list(updated.values()))) + storage.write( + "performance.json", + Performance(posts=list(updated.values()), page_traffic=existing.page_traffic), + ) logger.info("Per-post metrics: %d total, %d refreshed", len(updated), refreshed) except Exception: logger.exception("Per-post metrics collection failed") + + +PAGE_TRAFFIC_WINDOW_DAYS = 30 # matches _METRICS_REFRESH_DAYS's existing precedent +ANALYTICS_SITE = "fretchen.eu" # matches analytics/hit.ts's own hardcoded SITE + + +def _months_between(start: date, end: date) -> list[str]: + """Every YYYY-MM the [start, end] range touches, inclusive.""" + months = [] + y, m = start.year, start.month + while (y, m) <= (end.year, end.month): + months.append(f"{y:04d}-{m:02d}") + m += 1 + if m > 12: + m, y = 1, y + 1 + return months + + +def _sum_trailing_hits(rollups: dict[str, dict], window_start: date, today: date) -> dict[str, int]: + """Sum each page's hits for days in [window_start, today] across already-fetched + rollup objects (keyed YYYY-MM). Split out from the S3/env-var plumbing in + _collect_page_traffic so the month-boundary logic is testable without mocking S3. + """ + hits_by_page: dict[str, int] = defaultdict(int) + for rollup in rollups.values(): + for day, bucket in rollup.get("days", {}).items(): + if window_start.isoformat() <= day <= today.isoformat(): + for path, hits in bucket.get("pages", {}).items(): + hits_by_page[path] += hits + return dict(hits_by_page) + + +def _collect_page_traffic(storage) -> None: + """Trailing-30-day per-page hits from the analytics service's monthly rollups, + merged into performance.json. + + Reads from the same S3 bucket growth-agent already holds credentials for + (`rollup/{site}/{YYYY-MM}.json`, written by the separate `analytics` service) — + a second S3Storage instance with an empty prefix, not a new secret or HTTP + client. Rollup-only: recent days the weekly compaction cron hasn't reached yet + are simply absent from the sum, an accepted, self-healing staleness gap of up + to ~6 days rather than replicating the hourly-bucket fallback here too. + """ + try: + analytics_storage = S3Storage( + bucket=os.environ["S3_BUCKET"], + prefix="", + access_key=os.environ["SCW_ACCESS_KEY"], + secret_key=os.environ["SCW_SECRET_KEY"], + ) + today = datetime.now(timezone.utc).date() + window_start = today - timedelta(days=PAGE_TRAFFIC_WINDOW_DAYS - 1) + months = _months_between(window_start, today) + rollups: dict[str, dict] = {} + for month in months: + data = analytics_storage.read(f"rollup/{ANALYTICS_SITE}/{month}.json") + rollups[month] = data if isinstance(data, dict) else {"days": {}} + + hits_by_page = _sum_trailing_hits(rollups, window_start, today) + + performance = load_model(storage, "performance.json", Performance) + performance.page_traffic = hits_by_page + storage.write("performance.json", performance) + logger.info("Page traffic: %d pages, window %s..%s", len(hits_by_page), window_start, today) + except Exception: + logger.exception("Page traffic collection failed") diff --git a/growth-agent/notebooks/01_analytics_ingest.ipynb b/growth-agent/notebooks/01_analytics_ingest.ipynb new file mode 100644 index 000000000..daae3c311 --- /dev/null +++ b/growth-agent/notebooks/01_analytics_ingest.ipynb @@ -0,0 +1,481 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "170447c3", + "metadata": {}, + "source": [ + "# 01 — Analytics Ingestion\n", + "\n", + "Validate growth-agent's analytics ingestion against real Scaleway S3 data, entirely\n", + "inside the `growth-agent-dev/` sandbox prefix:\n", + "- Per-post engagement metrics (`_collect_post_metrics`)\n", + "- Page-traffic from the self-hosted `analytics` service's monthly rollups (`_collect_page_traffic`)\n", + "\n", + "Umami Cloud is gone — it was fully replaced this session by the self-hosted `analytics`\n", + "service (a separate package in this monorepo that beacons pageviews straight to S3, no\n", + "external API). `agent/umami_client.py` no longer exists and `WebsiteAnalytics` no longer\n", + "exists in `agent/models.py`, so this notebook no longer touches either." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "842746b4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:26.764910Z", + "iopub.status.busy": "2026-08-17T07:06:26.764798Z", + "iopub.status.idle": "2026-08-17T07:06:26.775063Z", + "shell.execute_reply": "2026-08-17T07:06:26.774566Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using S3 prefix: growth-agent-dev/ ✓\n" + ] + } + ], + "source": [ + "import sys\n", + "sys.path.insert(0, '..')\n", + "from dotenv import load_dotenv\n", + "load_dotenv('../.env', override=True)\n", + "\n", + "import os\n", + "prefix = os.getenv(\"S3_STATE_PREFIX\", \"growth-agent/\")\n", + "if prefix == \"growth-agent/\":\n", + " raise RuntimeError(\n", + " f\"S3_STATE_PREFIX is {prefix!r} — this is the PRODUCTION prefix. \"\n", + " \"Set S3_STATE_PREFIX=growth-agent-dev/ in .env before running notebooks.\"\n", + " )\n", + "print(f\"Using S3 prefix: {prefix} ✓\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "67e054e8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:26.776579Z", + "iopub.status.busy": "2026-08-17T07:06:26.776481Z", + "iopub.status.idle": "2026-08-17T07:06:27.033668Z", + "shell.execute_reply": "2026-08-17T07:06:27.032911Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "store targets s3://my-imagestore/growth-agent-dev/\n" + ] + } + ], + "source": [ + "from agent.storage import S3Storage\n", + "\n", + "store = S3Storage(\n", + " bucket=os.getenv('S3_BUCKET'),\n", + " prefix=os.getenv('S3_STATE_PREFIX'),\n", + " access_key=os.getenv('SCW_ACCESS_KEY'),\n", + " secret_key=os.getenv('SCW_SECRET_KEY'),\n", + ")\n", + "print(f\"store targets s3://{store.bucket}/{store.prefix}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cbb11483", + "metadata": {}, + "source": [ + "## 1. Pull real published-post data into the dev sandbox\n", + "\n", + "Copy the production `content_queue.json` into the dev prefix (read-only from prod).\n", + "Done once here — shared by the per-post-metrics section below and the page-traffic\n", + "section further down, so real published-post links (and their real analytics traffic)\n", + "are visible in both." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "62f15f9e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:27.035223Z", + "iopub.status.busy": "2026-08-17T07:06:27.035040Z", + "iopub.status.idle": "2026-08-17T07:06:27.847231Z", + "shell.execute_reply": "2026-08-17T07:06:27.846448Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Copied growth-agent/content_queue.json → growth-agent-dev/content_queue.json\n" + ] + } + ], + "source": [ + "# Copy prod content_queue.json → dev prefix (prod is read-only here)\n", + "import boto3\n", + "\n", + "prod_prefix = os.environ[\"S3_STATE_PREFIX_PROD\"] # e.g. \"growth-agent/\"\n", + "dev_prefix = os.getenv(\"S3_STATE_PREFIX\", \"growth-agent-dev/\")\n", + "bucket = os.environ[\"S3_BUCKET\"]\n", + "\n", + "s3_raw = boto3.client(\n", + " \"s3\",\n", + " region_name=\"nl-ams\",\n", + " endpoint_url=\"https://s3.nl-ams.scw.cloud\",\n", + " aws_access_key_id=os.environ[\"SCW_ACCESS_KEY\"],\n", + " aws_secret_access_key=os.environ[\"SCW_SECRET_KEY\"],\n", + ")\n", + "s3_raw.copy_object(\n", + " Bucket=bucket,\n", + " CopySource={\"Bucket\": bucket, \"Key\": prod_prefix + \"content_queue.json\"},\n", + " Key=dev_prefix + \"content_queue.json\",\n", + ")\n", + "print(f\"Copied {prod_prefix}content_queue.json → {dev_prefix}content_queue.json\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "2dc16852", + "metadata": {}, + "source": [ + "## 2. Per-post engagement metrics" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0033f75e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:27.849855Z", + "iopub.status.busy": "2026-08-17T07:06:27.849681Z", + "iopub.status.idle": "2026-08-17T07:06:37.495134Z", + "shell.execute_reply": "2026-08-17T07:06:37.494254Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done — performance.json written to dev prefix\n" + ] + } + ], + "source": [ + "from agent.nodes.ingest import _collect_post_metrics\n", + "\n", + "_collect_post_metrics(store)\n", + "print(\"Done — performance.json written to dev prefix\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "77b5332a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:37.497462Z", + "iopub.status.busy": "2026-08-17T07:06:37.497312Z", + "iopub.status.idle": "2026-08-17T07:06:37.849689Z", + "shell.execute_reply": "2026-08-17T07:06:37.848896Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Posts with metrics: 59\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 1 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 1 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 1 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — recovered_mastodon_20260…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202606…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202606…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202604…\n", + " [bluesky ] ❤️ 2 🔁 0 💬 1 — recovered_bluesky_202604…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202604…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 2 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 1 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 1 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 1 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 1 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202605…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202606…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202606…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — recovered_bluesky_202606…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026060…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026060…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [mastodon] ❤️ 0 🔁 0 💬 0 — draft_mastodon_en_202607…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026070…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026071…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026071…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026072…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026072…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026072…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026072…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026080…\n", + " [bluesky ] ❤️ 2 🔁 0 💬 2 — draft_bluesky_en_2026080…\n", + " [bluesky ] ❤️ 0 🔁 0 💬 0 — draft_bluesky_en_2026080…\n" + ] + } + ], + "source": [ + "from agent.storage import load_model\n", + "from agent.models import Performance\n", + "\n", + "perf = load_model(store, \"performance.json\", Performance)\n", + "print(f\"Posts with metrics: {len(perf.posts)}\")\n", + "for p in perf.posts:\n", + " print(f\" [{p.channel:8}] ❤️{p.favourites:3} 🔁{p.reblogs:3} 💬{p.replies:3} — {p.id[:24]}…\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "57efd813", + "metadata": {}, + "source": [ + "## 3. Inspect the real analytics rollups directly (read-only)\n", + "\n", + "The `analytics` service (a separate package) writes monthly rollups to\n", + "`rollup/fretchen.eu/{YYYY-MM}.json` at the **bucket root** — not under any\n", + "`growth-agent[-dev]/` prefix. This read is always real production analytics data and is\n", + "safe regardless of `S3_STATE_PREFIX`, because it never writes anything.\n", + "\n", + "This mirrors exactly what `_collect_page_traffic` does internally, reusing its own\n", + "`_sum_trailing_hits` helper rather than reimplementing the month-boundary summation." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7cd778b7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:37.851700Z", + "iopub.status.busy": "2026-08-17T07:06:37.851569Z", + "iopub.status.idle": "2026-08-17T07:06:38.546941Z", + "shell.execute_reply": "2026-08-17T07:06:38.546028Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Window: 2026-07-20 .. 2026-08-18 (39 pages)\n", + " 61 /\n", + " 17 /blog/16/\n", + " 4 /quantum/hardware/2/\n", + " 4 /quantum/amo/\n", + " 3 /blog/29/\n", + " 3 /blog/25/\n", + " 3 /blog/7/\n", + " 2 /blog/9/\n", + " 2 /quantum/amo/18/\n", + " 2 /quantum/amo/12/\n", + " 2 /notebook-smoke-test\n", + " 2 /analytics/\n", + " 2 /blog/23/\n", + " 2 /blog/24/\n", + " 2 /blog/27/\n", + "\n", + "/x402/ (read-only, real rollups): None\n" + ] + } + ], + "source": [ + "from datetime import datetime, timedelta, timezone\n", + "from agent.nodes.ingest import _sum_trailing_hits, PAGE_TRAFFIC_WINDOW_DAYS, ANALYTICS_SITE\n", + "\n", + "analytics_storage = S3Storage(\n", + " bucket=os.environ[\"S3_BUCKET\"],\n", + " prefix=\"\",\n", + " access_key=os.environ[\"SCW_ACCESS_KEY\"],\n", + " secret_key=os.environ[\"SCW_SECRET_KEY\"],\n", + ")\n", + "\n", + "today = datetime.now(timezone.utc).date()\n", + "window_start = today - timedelta(days=PAGE_TRAFFIC_WINDOW_DAYS - 1)\n", + "months = sorted({window_start.strftime(\"%Y-%m\"), today.strftime(\"%Y-%m\")})\n", + "rollups = {}\n", + "for month in months:\n", + " data = analytics_storage.read(f\"rollup/{ANALYTICS_SITE}/{month}.json\")\n", + " rollups[month] = data if isinstance(data, dict) else {\"days\": {}}\n", + "\n", + "hits_by_page = _sum_trailing_hits(rollups, window_start, today)\n", + "\n", + "print(f\"Window: {window_start} .. {today} ({len(hits_by_page)} pages)\")\n", + "for path, hits in sorted(hits_by_page.items(), key=lambda kv: -kv[1])[:15]:\n", + " print(f\" {hits:>6} {path}\")\n", + "\n", + "print()\n", + "print(f\"/x402/ (read-only, real rollups): {hits_by_page.get('/x402/')}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3c77d76a", + "metadata": {}, + "source": [ + "## 4. Run `_collect_page_traffic` against the dev prefix\n", + "\n", + "Merges into the dev prefix's `performance.json`, preserving the `posts` written in\n", + "section 2 above — the same safe write order `ingest_analytics()` uses in production\n", + "(page traffic is always collected *after* post metrics, since `_collect_post_metrics`\n", + "does a fresh, non-merging write)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "0fa9719e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:38.549085Z", + "iopub.status.busy": "2026-08-17T07:06:38.548937Z", + "iopub.status.idle": "2026-08-17T07:06:40.478047Z", + "shell.execute_reply": "2026-08-17T07:06:40.477328Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done — page_traffic merged into performance.json (dev prefix)\n" + ] + } + ], + "source": [ + "from agent.nodes.ingest import _collect_page_traffic\n", + "\n", + "_collect_page_traffic(store)\n", + "print(\"Done — page_traffic merged into performance.json (dev prefix)\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "4334ecae", + "metadata": {}, + "source": [ + "## 5. Verify: reload `performance.json` from the dev prefix" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "0cb12f66", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-17T07:06:40.480380Z", + "iopub.status.busy": "2026-08-17T07:06:40.480213Z", + "iopub.status.idle": "2026-08-17T07:06:40.711219Z", + "shell.execute_reply": "2026-08-17T07:06:40.710227Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Posts still present: 59\n", + "page_traffic: 39 pages\n", + " 61 /\n", + " 17 /blog/16/\n", + " 4 /quantum/hardware/2/\n", + " 4 /quantum/amo/\n", + " 3 /blog/29/\n", + " 3 /blog/25/\n", + " 3 /blog/7/\n", + " 2 /blog/9/\n", + " 2 /quantum/amo/18/\n", + " 2 /quantum/amo/12/\n", + " 2 /notebook-smoke-test\n", + " 2 /analytics/\n", + " 2 /blog/23/\n", + " 2 /blog/24/\n", + " 2 /blog/27/\n", + "\n", + "/x402/ (from dev performance.json): None\n", + "Should match the read-only figure printed in section 3.\n" + ] + } + ], + "source": [ + "perf = load_model(store, \"performance.json\", Performance)\n", + "\n", + "print(f\"Posts still present: {len(perf.posts)}\")\n", + "print(f\"page_traffic: {len(perf.page_traffic)} pages\")\n", + "for path, hits in sorted(perf.page_traffic.items(), key=lambda kv: -kv[1])[:15]:\n", + " print(f\" {hits:>6} {path}\")\n", + "\n", + "print()\n", + "print(f\"/x402/ (from dev performance.json): {perf.page_traffic.get('/x402/')}\")\n", + "print(\"Should match the read-only figure printed in section 3.\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (growth-agent)", + "language": "python", + "name": "growth-agent" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/growth-agent/notebooks/01_umami_ingest.ipynb b/growth-agent/notebooks/01_umami_ingest.ipynb deleted file mode 100644 index d4fbea5dd..000000000 --- a/growth-agent/notebooks/01_umami_ingest.ipynb +++ /dev/null @@ -1,366 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "cb37534f", - "metadata": {}, - "source": [ - "# 01 — Umami Analytics Ingestion\n", - "\n", - "Validate the Umami Cloud REST API integration:\n", - "- Connect with API key\n", - "- Fetch website stats, pageviews, metrics, events\n", - "- Parse into our `Insights` model\n", - "- Save to local state (mock S3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f211f66d", - "metadata": {}, - "outputs": [], - "source": [ - "import sys\n", - "sys.path.insert(0, '..')\n", - "from dotenv import load_dotenv\n", - "load_dotenv('../.env', override=True)\n", - "\n", - "import os\n", - "prefix = os.getenv(\"S3_STATE_PREFIX\", \"growth-agent/\")\n", - "if prefix == \"growth-agent/\":\n", - " raise RuntimeError(\n", - " f\"S3_STATE_PREFIX is {prefix!r} — this is the PRODUCTION prefix. \"\n", - " \"Set S3_STATE_PREFIX=growth-agent-dev/ in .env before running notebooks.\"\n", - " )\n", - "print(f\"Using S3 prefix: {prefix} ✓\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7195f5ab", - "metadata": {}, - "outputs": [], - "source": [ - "import sys\n", - "sys.path.insert(0, '..')\n", - "\n", - "from dotenv import load_dotenv\n", - "import os\n", - "\n", - "load_dotenv('../.env', override=True)\n", - "\n", - "UMAMI_API_KEY = os.getenv('UMAMI_API_KEY')\n", - "UMAMI_WEBSITE_ID = os.getenv('UMAMI_WEBSITE_ID', 'e41ae7d9-a536-426d-b40e-f2488b11bf95')\n", - "\n", - "print(f'API Key loaded: {\"yes\" if UMAMI_API_KEY else \"NO — create .env from .env.example\"}')\n", - "print(f'Website ID: {UMAMI_WEBSITE_ID}')" - ] - }, - { - "cell_type": "markdown", - "id": "fa68278b", - "metadata": {}, - "source": [ - "## 1. Connect to Umami Cloud API\n", - "\n", - "Umami Cloud base URL: `https://api.umami.is/v1` \n", - "Auth: `x-umami-api-key` header \n", - "Rate limit: 50 calls / 15 seconds" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c84ac315", - "metadata": {}, - "outputs": [], - "source": [ - "from agent.umami_client import UmamiClient, ms_timestamp\n", - "\n", - "umami = UmamiClient(api_key=UMAMI_API_KEY, website_id=UMAMI_WEBSITE_ID)\n", - "\n", - "# Test connection: get active users\n", - "active = umami.get_active()\n", - "print(f'Active users right now: {active}')" - ] - }, - { - "cell_type": "markdown", - "id": "c2e7bfea", - "metadata": {}, - "source": [ - "## 2. Fetch Website Stats (last 7 days)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a9bdf799", - "metadata": {}, - "outputs": [], - "source": [ - "start = ms_timestamp(days_ago=7)\n", - "end = ms_timestamp(days_ago=0)\n", - "\n", - "stats = umami.get_stats(start_at=start, end_at=end)\n", - "print('Website stats (7 days):')\n", - "for k, v in stats.items():\n", - " if k != 'comparison':\n", - " print(f' {k}: {v}')" - ] - }, - { - "cell_type": "markdown", - "id": "2c344abe", - "metadata": {}, - "source": [ - "## 3. Top Pages" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "faffe5d7", - "metadata": {}, - "outputs": [], - "source": [ - "top_pages = umami.get_metrics(start_at=start, end_at=end, metric_type='path', limit=15)\n", - "print('Top pages:')\n", - "for page in top_pages:\n", - " print(f' {page[\"y\"]:>5} visitors — {page[\"x\"]}')" - ] - }, - { - "cell_type": "markdown", - "id": "1b221660", - "metadata": {}, - "source": [ - "## 4. Top Referrers" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "751f1beb", - "metadata": {}, - "outputs": [], - "source": [ - "referrers = umami.get_metrics(start_at=start, end_at=end, metric_type='referrer', limit=15)\n", - "print('Top referrers:')\n", - "for ref in referrers:\n", - " print(f' {ref[\"y\"]:>5} visitors — {ref[\"x\"]}')" - ] - }, - { - "cell_type": "markdown", - "id": "ac3c3e4d", - "metadata": {}, - "source": [ - "## 5. Events (tracked funnels)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5fd14e63", - "metadata": {}, - "outputs": [], - "source": [ - "events = umami.get_metrics(start_at=start, end_at=end, metric_type='event', limit=30)\n", - "print('Tracked events:')\n", - "for event in events:\n", - " print(f' {event[\"y\"]:>5}x — {event[\"x\"]}')" - ] - }, - { - "cell_type": "markdown", - "id": "5327a9e4", - "metadata": {}, - "source": [ - "## 6. Pageviews Over Time" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f7f6f373", - "metadata": {}, - "outputs": [], - "source": [ - "pageviews = umami.get_pageviews(start_at=start, end_at=end, unit='day')\n", - "print('Daily pageviews:')\n", - "for pv in pageviews.get('pageviews', []):\n", - " print(f' {pv[\"x\"][:10]} — {pv[\"y\"]} views')\n", - "\n", - "print('\\nDaily sessions:')\n", - "for s in pageviews.get('sessions', []):\n", - " print(f' {s[\"x\"][:10]} — {s[\"y\"]} sessions')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "128094e3", - "metadata": {}, - "outputs": [], - "source": [ - "from datetime import datetime\n", - "from agent.models import Insights, WebsiteAnalytics\n", - "\n", - "insights = Insights(\n", - " website_analytics=WebsiteAnalytics(\n", - " pageviews=stats.get('pageviews', 0),\n", - " visitors=stats.get('visitors', 0),\n", - " visits=stats.get('visits', 0),\n", - " bounces=stats.get('bounces', 0),\n", - " totaltime=stats.get('totaltime', 0),\n", - " top_pages=top_pages[:10],\n", - " top_referrers=referrers[:10],\n", - " top_events=events[:10],\n", - " ),\n", - " last_analysis=datetime.now(),\n", - ")\n", - "\n", - "print(f'Insights parsed: {insights.website_analytics.visitors} visitors, '\n", - " f'{len(insights.website_analytics.top_pages)} top pages')\n" - ] - }, - { - "cell_type": "markdown", - "id": "a75d297d", - "metadata": {}, - "source": [ - "## 7. Save to S3 State" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8fce0a0e", - "metadata": {}, - "outputs": [], - "source": [ - "from agent.storage import S3Storage\n", - "\n", - "store = S3Storage(\n", - " bucket=os.getenv('S3_BUCKET'),\n", - " prefix=os.getenv('S3_STATE_PREFIX', 'growth-agent/'),\n", - " access_key=os.getenv('SCW_ACCESS_KEY'),\n", - " secret_key=os.getenv('SCW_SECRET_KEY'),\n", - ")\n", - "store.write('insights.json', insights)\n", - "\n", - "# Verify round-trip\n", - "loaded = store.read('insights.json')\n", - "print(f'Saved and loaded insights.json ({len(str(loaded))} chars)')\n", - "print(f'Top page: {loaded[\"website_analytics\"][\"top_pages\"][0] if loaded[\"website_analytics\"][\"top_pages\"] else \"none\"}')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "31325795", - "metadata": {}, - "outputs": [], - "source": [ - "umami.close()\n", - "print('Done — Umami ingestion validated.')" - ] - }, - { - "cell_type": "markdown", - "id": "fa825976", - "metadata": {}, - "source": [ - "## 8. Per-post Engagement Metrics\n", - "\n", - "Copy the production `content_queue.json` into the dev prefix (read-only from prod), then run `_collect_post_metrics()` to validate the new metrics collection code.\n", - "\n", - "Requires `S3_STATE_PREFIX_PROD` env var set to the production prefix (e.g. `growth-agent/`)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "367795f5", - "metadata": {}, - "outputs": [], - "source": [ - "# Copy prod content_queue.json → dev prefix (prod is read-only here)\n", - "import boto3\n", - "\n", - "prod_prefix = os.environ[\"S3_STATE_PREFIX_PROD\"] # e.g. \"growth-agent/\"\n", - "dev_prefix = os.getenv(\"S3_STATE_PREFIX\", \"growth-agent-dev/\")\n", - "bucket = os.environ[\"S3_BUCKET\"]\n", - "\n", - "s3_raw = boto3.client(\n", - " \"s3\",\n", - " region_name=\"nl-ams\",\n", - " endpoint_url=\"https://s3.nl-ams.scw.cloud\",\n", - " aws_access_key_id=os.environ[\"SCW_ACCESS_KEY\"],\n", - " aws_secret_access_key=os.environ[\"SCW_SECRET_KEY\"],\n", - ")\n", - "s3_raw.copy_object(\n", - " Bucket=bucket,\n", - " CopySource={\"Bucket\": bucket, \"Key\": prod_prefix + \"content_queue.json\"},\n", - " Key=dev_prefix + \"content_queue.json\",\n", - ")\n", - "print(f\"Copied {prod_prefix}content_queue.json → {dev_prefix}content_queue.json\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "58464d3b", - "metadata": {}, - "outputs": [], - "source": [ - "# Run metrics collection against the copied queue\n", - "from agent.nodes.ingest import _collect_post_metrics\n", - "\n", - "_collect_post_metrics(store)\n", - "print(\"Done — performance.json written to dev prefix\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7888d526", - "metadata": {}, - "outputs": [], - "source": [ - "# Display results\n", - "from agent.storage import load_model\n", - "from agent.models import Performance\n", - "\n", - "perf = load_model(store, \"performance.json\", Performance)\n", - "print(f\"Posts with metrics: {len(perf.posts)}\")\n", - "for p in perf.posts:\n", - " print(f\" [{p.channel:8}] ❤️{p.favourites:3} 🔁{p.reblogs:3} 💬{p.replies:3} — {p.id[:24]}…\")\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python (growth-agent)", - "language": "python", - "name": "growth-agent" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/growth-agent/terraform/variables.tf b/growth-agent/terraform/variables.tf index fe3b65f36..969d083e2 100644 --- a/growth-agent/terraform/variables.tf +++ b/growth-agent/terraform/variables.tf @@ -44,6 +44,10 @@ variable "mistral_api_key" { # --- Non-sensitive configuration --- variable "s3_bucket" { + # The separate `analytics` service hardcodes this same bucket name with no + # override (growth-agent's _collect_page_traffic reads its rollup data from + # there) — if this variable is ever changed, that hardcoded value must be + # updated to match, or page traffic collection will silently go empty. description = "S3 bucket name for state storage" type = string default = "my-imagestore" diff --git a/growth-agent/test/test_handler.py b/growth-agent/test/test_handler.py index 30bbebd23..54889c07b 100644 --- a/growth-agent/test/test_handler.py +++ b/growth-agent/test/test_handler.py @@ -1,7 +1,7 @@ """Tests for growth-agent — nodes, graph, and handler.""" import json -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest @@ -23,7 +23,13 @@ _former_posts_context, create_drafts, ) -from agent.nodes.ingest import _collect_post_metrics, ingest_analytics +from agent.nodes.ingest import ( + _collect_page_traffic, + _collect_post_metrics, + _months_between, + _sum_trailing_hits, + ingest_analytics, +) from agent.nodes.insights import generate_insights from agent.nodes.plan import ( PIPELINE_TARGET, @@ -363,6 +369,167 @@ def test_collect_post_metrics_skips_no_published_at(mock_storage): assert perf.posts == [] +def test_collect_post_metrics_preserves_page_traffic(mock_storage): + """_collect_post_metrics's write is a fresh Performance(posts=...); it must + still carry forward any page_traffic collected by an earlier ingest step.""" + storage, store = mock_storage + storage.write("content_queue.json", ContentQueue()) + storage.write( + "performance.json", + Performance(posts=[], page_traffic={"/blog/a/": 42}), + ) + + _collect_post_metrics(storage) + + perf = Performance.model_validate(store["performance.json"]) + assert perf.page_traffic == {"/blog/a/": 42} + + +# --------------------------------------------------------------------------- +# _sum_trailing_hits / _collect_page_traffic +# --------------------------------------------------------------------------- + + +def test_sum_trailing_hits_sums_within_window(): + rollups = { + "2026-01": { + "days": { + "2026-01-30": {"pages": {"/blog/a/": 3, "/blog/b/": 1}}, + "2026-01-31": {"pages": {"/blog/a/": 2}}, + } + } + } + window_start = date(2026, 1, 30) + today = date(2026, 1, 31) + + result = _sum_trailing_hits(rollups, window_start, today) + + assert result == {"/blog/a/": 5, "/blog/b/": 1} + + +def test_sum_trailing_hits_excludes_days_outside_window(): + rollups = { + "2026-01": { + "days": { + "2026-01-01": {"pages": {"/blog/old/": 100}}, + "2026-01-31": {"pages": {"/blog/a/": 2}}, + } + } + } + window_start = date(2026, 1, 30) + today = date(2026, 1, 31) + + result = _sum_trailing_hits(rollups, window_start, today) + + assert result == {"/blog/a/": 2} + + +def test_sum_trailing_hits_spans_month_boundary(): + """The window-start/today comparison is a plain string compare, so a window + that straddles two monthly rollup objects must still sum both correctly.""" + rollups = { + "2026-01": {"days": {"2026-01-31": {"pages": {"/blog/a/": 1}}}}, + "2026-02": {"days": {"2026-02-01": {"pages": {"/blog/a/": 4}}}}, + } + window_start = date(2026, 1, 31) + today = date(2026, 2, 1) + + result = _sum_trailing_hits(rollups, window_start, today) + + assert result == {"/blog/a/": 5} + + +def test_sum_trailing_hits_empty_rollups(): + assert _sum_trailing_hits({}, date(2026, 1, 1), date(2026, 1, 31)) == {} + + +def test_months_between_spans_three_calendar_months(): + """A 30-day window can fully span a short middle month (e.g. February) — + every touched month must be enumerated, not just the two endpoints.""" + assert _months_between(date(2027, 1, 31), date(2027, 3, 1)) == [ + "2027-01", + "2027-02", + "2027-03", + ] + + +def test_months_between_single_month(): + assert _months_between(date(2026, 8, 1), date(2026, 8, 20)) == ["2026-08"] + + +def test_months_between_year_boundary(): + assert _months_between(date(2026, 12, 15), date(2027, 1, 5)) == ["2026-12", "2027-01"] + + +@patch("agent.nodes.ingest.S3Storage") +def test_collect_page_traffic_merges_into_performance(MockS3Storage, mock_storage): + storage, store = mock_storage + storage.write("performance.json", Performance(posts=[])) + + today = datetime.now(timezone.utc).date() + today_month = today.strftime("%Y-%m") + + def read_for_month(key: str): + # Only the current month's rollup contains today's data — the other + # requested month (if the trailing window spans a boundary) must come + # back empty, or the same day would be double-counted across months. + if today_month in key: + return {"days": {today.isoformat(): {"pages": {"/blog/a/": 7}}}} + return {"days": {}} + + analytics_instance = MagicMock() + analytics_instance.read.side_effect = read_for_month + MockS3Storage.return_value = analytics_instance + + _collect_page_traffic(storage) + + perf = Performance.model_validate(store["performance.json"]) + assert perf.page_traffic == {"/blog/a/": 7} + + +@patch("agent.nodes.ingest.S3Storage") +def test_collect_page_traffic_preserves_existing_posts(MockS3Storage, mock_storage): + """A read-modify-write against performance.json — must not clobber posts + written earlier in the same ingest run by _collect_post_metrics.""" + storage, store = mock_storage + storage.write( + "performance.json", + Performance( + posts=[ + PostMetrics( + id="p1", + channel="mastodon", + published_at=datetime.now(timezone.utc).isoformat(), + ) + ] + ), + ) + + analytics_instance = MagicMock() + analytics_instance.read.return_value = None + MockS3Storage.return_value = analytics_instance + + _collect_page_traffic(storage) + + perf = Performance.model_validate(store["performance.json"]) + assert len(perf.posts) == 1 + assert perf.posts[0].id == "p1" + assert perf.page_traffic == {} + + +@patch("agent.nodes.ingest.S3Storage") +def test_collect_page_traffic_failure_leaves_performance_untouched(MockS3Storage, mock_storage): + storage, store = mock_storage + storage.write("performance.json", Performance(posts=[])) + + MockS3Storage.side_effect = Exception("S3 unreachable") + + _collect_page_traffic(storage) # must not raise + + perf = Performance.model_validate(store["performance.json"]) + assert perf.page_traffic == {} + + # --------------------------------------------------------------------------- # publish_approved_drafts # --------------------------------------------------------------------------- diff --git a/scw_js/growth_service.ts b/scw_js/growth_service.ts index bd2541ca0..47dae7aa4 100644 --- a/scw_js/growth_service.ts +++ b/scw_js/growth_service.ts @@ -67,6 +67,9 @@ export interface PostMetrics { export interface Performance { posts: PostMetrics[]; + // Page path -> hits in the trailing 30 days, from the analytics service's + // monthly rollups. Absent on performance.json written before this field existed. + page_traffic?: Record; } // ===== S3 helpers ===== diff --git a/website/pages/growth/+Page.tsx b/website/pages/growth/+Page.tsx index 389b4cae6..06965cff7 100644 --- a/website/pages/growth/+Page.tsx +++ b/website/pages/growth/+Page.tsx @@ -541,6 +541,7 @@ export default function Page() { totalEngagement: { favourites: number; reblogs: number; replies: number }; lastPublished: string | null; channels: Set; + pageViews30d: number | undefined; } >(); @@ -561,6 +562,7 @@ export default function Page() { totalEngagement: { favourites: 0, reblogs: 0, replies: 0 }, lastPublished: null, channels: new Set(), + pageViews30d: performance?.page_traffic?.[path], }); } const group = map.get(key)!; @@ -580,7 +582,7 @@ export default function Page() { const totalEng = (g: { totalEngagement: { favourites: number; reblogs: number; replies: number } }) => g.totalEngagement.favourites + g.totalEngagement.reblogs + g.totalEngagement.replies; return [...map.values()].sort((a, b) => totalEng(b) - totalEng(a)); - }, [queue?.published, metricsByDraftId]); + }, [queue?.published, metricsByDraftId, performance?.page_traffic]); const handleApprove = async (id: string, scheduledAt?: string, reviewComment?: string) => { await approveMutation.mutateAsync({ id, scheduledAt, reviewComment }); @@ -701,6 +703,9 @@ export default function Page() { ❤️ {eng.favourites} 🔁 {eng.reblogs} 💬 {eng.replies} )} + {group.pageViews30d !== undefined && ( + 👁 {group.pageViews30d} (30d) + )}
{group.drafts.map((draft) => ( diff --git a/website/test/GrowthPage.test.tsx b/website/test/GrowthPage.test.tsx index 5219d10a6..dbdb47a9b 100644 --- a/website/test/GrowthPage.test.tsx +++ b/website/test/GrowthPage.test.tsx @@ -8,6 +8,7 @@ import { buildAccountData, buildConnectData } from "./setup"; // Mock the new TQ-based growth hooks const mockUseGrowthDrafts = vi.fn(); const mockUseGrowthInsights = vi.fn(); +const mockUseGrowthPerformance = vi.fn(); const mockApproveMutateAsync = vi.fn(); const mockRejectMutateAsync = vi.fn(); const mockUpdateMutateAsync = vi.fn(); @@ -21,7 +22,7 @@ let mockApproveError: Error | null = null; vi.mock("../hooks/useGrowthApi", () => ({ useGrowthDrafts: (...args: unknown[]) => mockUseGrowthDrafts(...args), useGrowthInsights: (...args: unknown[]) => mockUseGrowthInsights(...args), - useGrowthPerformance: () => ({ data: undefined, isPending: false }), + useGrowthPerformance: (...args: unknown[]) => mockUseGrowthPerformance(...args), useApproveDraft: () => ({ mutateAsync: mockApproveMutateAsync, isPending: false, @@ -70,6 +71,7 @@ describe("Growth Page", () => { isPending: false, error: null, }); + mockUseGrowthPerformance.mockReturnValue({ data: undefined, isPending: false }); mockApproveMutateAsync.mockResolvedValue({ ...sampleQueue.drafts[0], status: "approved" }); mockRejectMutateAsync.mockResolvedValue({ ...sampleQueue.drafts[0], status: "rejected" }); mockUpdateMutateAsync.mockResolvedValue({ ...sampleQueue.drafts[0], content: "Updated content" }); @@ -344,4 +346,78 @@ describe("Growth Page", () => { expect(screen.queryByText("Approve failed")).not.toBeInTheDocument(); }); }); + + describe("published tab — page traffic badge", () => { + const publishedQueue = { + drafts: [], + approved: [], + published: [ + { + id: "pub_1", + created: "2026-04-12T08:00:00Z", + channel: "mastodon", + language: "en", + content: "Check out this blog post about game theory!", + source_blog_post: "prisoners_dilemma", + hashtags: [], + link: "https://fretchen.eu/blog/prisoners_dilemma", + status: "published", + scheduled_at: null, + published_at: "2026-04-12T09:00:00Z", + }, + ], + rejected: [], + }; + + beforeEach(() => { + mockUseGrowthDrafts.mockReturnValue({ data: publishedQueue, isPending: false, error: null }); + vi.mocked(useAccount).mockReturnValue( + buildAccountData({ address: OWNER_ADDRESS, isConnected: true, status: "connected" }), + ); + }); + + it("shows a 30d views badge when page_traffic has an entry for the group's path", async () => { + mockUseGrowthPerformance.mockReturnValue({ + data: { posts: [], page_traffic: { "/blog/prisoners_dilemma/": 42 } }, + isPending: false, + }); + + render(); + + fireEvent.click(screen.getByText(/Published/)); + + await waitFor(() => { + expect(screen.getByText("👁 42 (30d)")).toBeInTheDocument(); + }); + }); + + it("omits the badge when page_traffic is missing the group's path", async () => { + mockUseGrowthPerformance.mockReturnValue({ + data: { posts: [], page_traffic: { "/blog/some-other-post/": 99 } }, + isPending: false, + }); + + render(); + + fireEvent.click(screen.getByText(/Published/)); + + await waitFor(() => { + expect(screen.getByText("Check out this blog post about game theory!")).toBeInTheDocument(); + }); + expect(screen.queryByText(/👁/)).not.toBeInTheDocument(); + }); + + it("omits the badge when performance data has no page_traffic at all", async () => { + mockUseGrowthPerformance.mockReturnValue({ data: { posts: [] }, isPending: false }); + + render(); + + fireEvent.click(screen.getByText(/Published/)); + + await waitFor(() => { + expect(screen.getByText("Check out this blog post about game theory!")).toBeInTheDocument(); + }); + expect(screen.queryByText(/👁/)).not.toBeInTheDocument(); + }); + }); }); diff --git a/website/types/growth.ts b/website/types/growth.ts index 96f0b1686..59fd644af 100644 --- a/website/types/growth.ts +++ b/website/types/growth.ts @@ -66,4 +66,7 @@ export interface PostMetrics { export interface Performance { posts: PostMetrics[]; + // Page path -> hits in the trailing 30 days, from the analytics service's + // monthly rollups. Absent on performance.json written before this field existed. + page_traffic?: Record; }