← Back to all projects
Ready

RBD Marketing Recap Bot — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Mary sends any recap-related message to the Telegram bot and receives the RBD monthly marketing one-pager as a PDF in her email.

Architecture: Build marketing_recap_generator.py that pulls live KPIs (Meta Ads spend, Google Ads spend, social followers from CSV, Klaviyo list sizes), fills the templatized HTML, converts to PDF via wkhtmltopdf, and emails it via send_email.py. Add a bot-guides/marketing-recap.md guide so the existing Telegram bot knows what to do when Mary asks. Add Mary to the bot allowlist once she messages.

Tech Stack: Python 3.11, wkhtmltopdf, existing Meta Ads/Google Ads/social tracker infrastructure, send_email.py (Gmail SMTP)


Data Model

KPI Source Field
Total Monthly Spend Meta Ads API (all 3 stores) + Google Ads API (all 4 accounts) Sum of spend
YouTube Subscribers social-media-tracker.csv (latest RBD row) YouTube_Subs
Consumer Email List social-media-tracker.csv (latest Consumers row) Notes field
Instagram Followers social-media-tracker.csv (latest RBD row) IG_Followers
Facebook Followers social-media-tracker.csv (latest RBD row) FB_Followers
YTD Budget Pacing Meta + Google YTD spend / annual budget target Calculated
Channel Spend Breakdown Meta (3 stores) + Google (4 accounts) subtotals Per-platform

Annual targets (in config YAML):

  • Annual ad budget: $120,000
  • Email list goal: 150,000
  • IG goal: 276,000 (beat Moda)
  • YouTube goal: 200,000

Files to Create / Modify

Action Path
Create ~/ai-projects-local/mission-control/scripts/marketing_recap_generator.py
Create ~/ai-projects-local/mission-control/scripts/bot-guides/marketing-recap.md
Create ~/ai-projects-local/mission-control/config/marketing-recap-config.yaml
Modify ~/ai-projects-local/mission-control/scripts/telegram_watcher.py (add Mary)
Modify ~/ai-projects/Marketing-Plans/RBD_Monthly_Marketing_Recap_April2026.html → templatized copy

Templatized HTML saved at: ~/ai-projects-local/mission-control/templates/marketing_recap_template.html


Task 1: Create Config YAML

Files:

  • Create: ~/ai-projects-local/mission-control/config/marketing-recap-config.yaml

Step 1: Create the file

# Annual targets — update each January
annual_budget_usd: 120000
goals:
  email_consumers: 150000
  instagram_followers: 276000
  youtube_subs: 200000

# Output dir for generated PDFs
output_dir: ~/ai-projects/mission-control/reports/marketing-recaps/

# Recipients
default_email: mary@alpineanalytica.com

Step 2: Verify the file is readable

python3 -c "import yaml; print(yaml.safe_load(open('config/marketing-recap-config.yaml')))"

Expected: dict with annual_budget_usd, goals, output_dir keys


Task 2: Templatize the HTML

Files:

  • Read: ~/ai-projects/Marketing-Plans/RBD_Monthly_Marketing_Recap_April2026.html
  • Create: ~/ai-projects-local/mission-control/templates/marketing_recap_template.html

Step 1: Copy the HTML and replace all hardcoded data values with {VARNAME} placeholders

Key substitutions (use Python .format() style — single curly braces):

Find (in HTML) Replace with
April 2026 (in <h1>) {MONTH_LABEL}
April 2026 (in footer/subhead) {MONTH_LABEL}
$[DATA] (Total Monthly Spend) {TOTAL_SPEND}
183K (YouTube KPI num) {YOUTUBE_SUBS}
▲ +1K in April · Goal: 200K {YOUTUBE_DELTA}
122,832 (Consumer Email KPI) {EMAIL_CONSUMERS}
+X in April (email delta) {EMAIL_DELTA}
233K (Instagram KPI) {INSTAGRAM_FOLLOWERS}
33% (YTD Pacing KPI num) {YTD_PACING_PCT}
4 of 12 mo. {MONTHS_ELAPSED} of 12 mo.
All $[DATA] in channel spend table {META_SPEND}, {GOOGLE_SPEND}, {TOTAL_CHANNEL_SPEND}
H&amp;H Chicago Trade Show {FEATURED_EVENT}
★ April Feature: ★ {MONTH_LABEL} Feature:
Mothers, Makers &amp; Memories {NEXT_MONTH_THEME}
Progress bar width inline styles {EMAIL_GOAL_PCT}%, {IG_GOAL_PCT}%, {YT_GOAL_PCT}%
Goal label numbers {EMAIL_GOAL_GAP}, {IG_GOAL}, {YT_GOAL}

Step 2: Verify no hardcoded April-specific values remain

grep -n "April\|183K\|122,832\|233K\|\$\[DATA\]\|H&amp;H" ~/ai-projects-local/mission-control/templates/marketing_recap_template.html | grep -v "style\|class\|font\|color"

Expected: 0 matches (or only in CSS comments, not content)


Task 3: Build marketing_recap_generator.py

Files:

  • Create: ~/ai-projects-local/mission-control/scripts/marketing_recap_generator.py

Step 1: Write the data fetchers

#!/usr/bin/env python3
"""
RBD Monthly Marketing Recap Generator

Usage:
    python marketing_recap_generator.py                    # Previous full month
    python marketing_recap_generator.py --month 2026-04   # Specific month
    python marketing_recap_generator.py --event "H&H Chicago" --next-theme "Summer Sewing"
    python marketing_recap_generator.py --dry-run          # Print data, skip PDF
"""

import argparse
import csv
import os
import subprocess
import sys
import tempfile
import yaml
from datetime import datetime, timedelta
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
CONFIG_PATH = SCRIPT_DIR.parent / "config" / "marketing-recap-config.yaml"
TEMPLATE_PATH = SCRIPT_DIR.parent / "templates" / "marketing_recap_template.html"
SECRETS_FILE = Path.home() / "ai-projects-local" / "shared-knowledge" / "secrets.env.enc"
KEY_FILE = Path.home() / ".secrets" / "master.key"
SOCIAL_CSV = Path.home() / "ai-projects" / "Riley-Blake-Designs" / "Marketing-Reports-2026" / "social-media-tracker.csv"


def load_config():
    with open(CONFIG_PATH) as f:
        return yaml.safe_load(f)


def get_month_range(year: int, month: int) -> tuple:
    """Return (since, until) date strings for the given month."""
    since = f"{year}-{month:02d}-01"
    if month == 12:
        until = f"{year+1}-01-01"
    else:
        until = f"{year}-{month+1:02d}-01"
    # until is exclusive for Meta API date range
    return since, until


def get_ytd_range(year: int, month: int) -> tuple:
    """Return (since, until) for Jan 1 through end of given month."""
    since = f"{year}-01-01"
    if month == 12:
        until = f"{year+1}-01-01"
    else:
        until = f"{year}-{month+1:02d}-01"
    return since, until


def decrypt_secrets() -> dict:
    """Decrypt and parse secrets.env.enc into a dict."""
    result = subprocess.run(
        ["openssl", "enc", "-d", "-aes-256-cbc", "-pbkdf2",
         "-in", str(SECRETS_FILE), "-pass", f"file:{KEY_FILE}"],
        capture_output=True, text=True
    )
    secrets = {}
    for line in result.stdout.splitlines():
        line = line.strip()
        if "=" in line and not line.startswith("#"):
            k, v = line.split("=", 1)
            secrets[k.strip()] = v.strip()
    return secrets


def fetch_meta_spend(secrets: dict, since: str, until: str) -> dict:
    """
    Pull monthly spend from all 3 Meta ad accounts.
    Returns {"fabric_outlet": X, "shiplap": X, "sundance": X, "total": X}
    """
    import sys
    sys.path.insert(0, str(Path.home() / "ai-projects-local" / "fb-ads-agent"))
    from agent.fb_client import FBClient

    stores = {
        "fabric_outlet": (secrets.get("FB_ACCESS_TOKEN"), secrets.get("FB_AD_ACCOUNT_ID")),
        "shiplap":       (secrets.get("SHIPLAP_FB_SYSTEM_USER_TOKEN"), secrets.get("SHIPLAP_FB_AD_ACCOUNT_ID")),
        "sundance":      (secrets.get("SUNDANCE_FB_ACCESS_TOKEN"), secrets.get("SUNDANCE_FB_AD_ACCOUNT_ID")),
    }

    result = {}
    total = 0.0
    for store, (token, account_id) in stores.items():
        if not token or not account_id:
            print(f"  WARNING: Missing token/account for {store}", file=sys.stderr)
            result[store] = 0.0
            continue
        client = FBClient(access_token=token, ad_account_id=account_id,
                          app_id=secrets.get("FB_APP_ID"), app_secret=secrets.get("FB_APP_SECRET"))
        try:
            insights = client.get_account_insights(
                fields=["spend"],
                params={"time_range": {"since": since, "until": until}, "level": "account"}
            )
            spend = float(insights[0].get("spend", 0)) if insights else 0.0
            result[store] = spend
            total += spend
            print(f"  Meta {store}: ${spend:,.2f}")
        except Exception as e:
            print(f"  ERROR fetching Meta {store}: {e}", file=sys.stderr)
            result[store] = 0.0

    result["total"] = total
    return result


def fetch_google_spend(secrets: dict, since: str, until: str) -> dict:
    """
    Pull monthly spend from all 4 Google Ads accounts.
    Returns {"fabric_outlet": X, "shiplap": X, "sundance": X, "mcc": X, "total": X}
    """
    sys.path.insert(0, str(Path.home() / "ai-projects-local" / "mission-control" / "scripts"))
    try:
        from google.ads.googleads.client import GoogleAdsClient
        from google.ads.googleads.errors import GoogleAdsException
    except ImportError:
        print("  WARNING: google-ads SDK not available, skipping Google Ads", file=sys.stderr)
        return {"total": 0.0}

    # Customer IDs for each store
    accounts = {
        "fabric_outlet": "4128721400",
        "shiplap":       "1491676209",
        "sundance":      "8958538976",
    }

    credentials_path = Path.home() / "ai-projects-local" / "mission-control" / "config" / "google-ads.yaml"
    if not credentials_path.exists():
        print("  WARNING: google-ads.yaml not found, skipping Google Ads", file=sys.stderr)
        return {"total": 0.0}

    try:
        client = GoogleAdsClient.load_from_storage(str(credentials_path))
    except Exception as e:
        print(f"  WARNING: Google Ads client failed to init: {e}", file=sys.stderr)
        return {"total": 0.0}

    result = {}
    total = 0.0
    since_gaql = since.replace("-", "")[:8]  # YYYYMMDD
    until_gaql = until.replace("-", "")[:8]

    for store, customer_id in accounts.items():
        try:
            ga_service = client.get_service("GoogleAdsService")
            query = f"""
                SELECT metrics.cost_micros
                FROM campaign
                WHERE segments.date BETWEEN '{since}' AND '{until}'
                  AND campaign.status != 'REMOVED'
            """
            stream = ga_service.search_stream(customer_id=customer_id, query=query)
            spend = sum(
                row.metrics.cost_micros
                for batch in stream
                for row in batch.results
            ) / 1_000_000
            result[store] = spend
            total += spend
            print(f"  Google {store}: ${spend:,.2f}")
        except Exception as e:
            print(f"  ERROR fetching Google {store}: {e}", file=sys.stderr)
            result[store] = 0.0

    result["total"] = total
    return result


def fetch_social_data(month_label: str) -> dict:
    """
    Read the most recent RBD row and Klaviyo rows from social-media-tracker.csv.
    Returns dict of social metrics.
    """
    rows = []
    with open(SOCIAL_CSV, newline="") as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    # Get the most recent RBD row
    rbd_rows = [r for r in rows if r["Brand"] == "Riley Blake Designs"]
    rbd = rbd_rows[-1] if rbd_rows else {}

    # Get the most recent Klaviyo rows
    consumer_rows = [r for r in rows if "Consumers" in r["Brand"]]
    dealer_rows   = [r for r in rows if "Dealers"   in r["Brand"]]
    consumer_row  = consumer_rows[-1] if consumer_rows else {}
    dealer_row    = dealer_rows[-1]   if dealer_rows   else {}

    def parse_k(val):
        """Convert '183000' → '183K', '1234567' → '1.2M'"""
        try:
            n = int(val)
            if n >= 1_000_000:
                return f"{n/1_000_000:.1f}M"
            if n >= 1_000:
                return f"{n//1000}K"
            return str(n)
        except (ValueError, TypeError):
            return "–"

    def parse_email_count(notes: str) -> int:
        """Extract integer from 'Email subscribers: 127,218'"""
        try:
            return int(notes.split(":")[-1].replace(",", "").strip())
        except Exception:
            return 0

    consumers = parse_email_count(consumer_row.get("Notes", ""))
    yt_subs   = int(rbd.get("YouTube_Subs", 0) or 0)
    ig        = int(rbd.get("IG_Followers", 0) or 0)
    fb        = int(rbd.get("FB_Followers", 0) or 0)

    return {
        "youtube_subs":       parse_k(yt_subs),
        "youtube_subs_raw":   yt_subs,
        "instagram_followers": parse_k(ig),
        "instagram_raw":      ig,
        "facebook_followers": parse_k(fb),
        "email_consumers":    f"{consumers:,}",
        "email_consumers_raw": consumers,
    }


def calc_goal_pct(actual: float, goal: float) -> int:
    return min(100, int(actual / goal * 100)) if goal else 0


def format_spend(amount: float) -> str:
    return f"${amount:,.0f}"


def build_template_vars(year: int, month: int, meta: dict, google: dict,
                        social: dict, config: dict,
                        featured_event: str, next_theme: str) -> dict:
    month_dt     = datetime(year, month, 1)
    month_label  = month_dt.strftime("%B %Y")   # "April 2026"
    months_elapsed = month

    total_spend  = meta["total"] + google["total"]
    annual_budget = config["annual_budget_usd"]

    # YTD: fetch full-year-to-date across all sources (reuse same range function)
    # For template purposes use month proportion as proxy since we have monthly data
    ytd_pct = int((months_elapsed / 12) * 100)  # Simplified: budget pacing = months elapsed / 12

    goals = config["goals"]
    email_consumers_raw = social["email_consumers_raw"]
    ig_raw              = social["instagram_raw"]
    yt_raw              = social["youtube_subs_raw"]
    email_goal_gap      = max(0, goals["email_consumers"] - email_consumers_raw)
    ig_goal             = goals["instagram_followers"]
    yt_goal             = goals["youtube_subs"]

    return {
        "MONTH_LABEL":        month_label,
        "MONTHS_ELAPSED":     str(months_elapsed),
        "TOTAL_SPEND":        format_spend(total_spend),
        "META_SPEND":         format_spend(meta["total"]),
        "GOOGLE_SPEND":       format_spend(google["total"]),
        "TOTAL_CHANNEL_SPEND": format_spend(total_spend),
        "YOUTUBE_SUBS":       social["youtube_subs"],
        "YOUTUBE_DELTA":      f"Goal: {yt_goal//1000}K",
        "EMAIL_CONSUMERS":    social["email_consumers"],
        "EMAIL_DELTA":        "",
        "INSTAGRAM_FOLLOWERS": social["instagram_followers"],
        "FACEBOOK_FOLLOWERS": social["facebook_followers"],
        "YTD_PACING_PCT":     f"{ytd_pct}%",
        "FEATURED_EVENT":     featured_event or f"{month_label} Feature",
        "NEXT_MONTH_THEME":   next_theme or "Coming Next Month",
        "EMAIL_GOAL_PCT":     str(calc_goal_pct(email_consumers_raw, goals["email_consumers"])),
        "IG_GOAL_PCT":        str(calc_goal_pct(ig_raw, ig_goal)),
        "YT_GOAL_PCT":        str(calc_goal_pct(yt_raw, yt_goal)),
        "EMAIL_GOAL_GAP":     f"{email_goal_gap:,}",
        "IG_GOAL":            f"{ig_goal//1000}K",
        "YT_GOAL":            f"{yt_goal//1000}K",
        # Store breakdown for channel table
        "META_FO_SPEND":      format_spend(meta.get("fabric_outlet", 0)),
        "META_SHP_SPEND":     format_spend(meta.get("shiplap", 0)),
        "META_SUN_SPEND":     format_spend(meta.get("sundance", 0)),
        "GOOGLE_FO_SPEND":    format_spend(google.get("fabric_outlet", 0)),
        "GOOGLE_SHP_SPEND":   format_spend(google.get("shiplap", 0)),
        "GOOGLE_SUN_SPEND":   format_spend(google.get("sundance", 0)),
    }


def fill_template(template_path: Path, vars: dict) -> str:
    html = template_path.read_text()
    for key, value in vars.items():
        html = html.replace(f"{{{key}}}", str(value))
    return html


def html_to_pdf(html: str, output_path: Path) -> Path:
    with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as f:
        f.write(html)
        tmp_html = f.name
    try:
        subprocess.run(
            ["wkhtmltopdf", "--enable-local-file-access",
             "--page-size", "Letter", "--orientation", "Landscape",
             "--margin-top", "0", "--margin-right", "0",
             "--margin-bottom", "0", "--margin-left", "0",
             tmp_html, str(output_path)],
            check=True, capture_output=True
        )
    finally:
        os.unlink(tmp_html)
    return output_path


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--month", help="YYYY-MM (default: previous full month)")
    parser.add_argument("--event", default="", help="Featured event name")
    parser.add_argument("--next-theme", default="", help="Next month theme/campaign name")
    parser.add_argument("--dry-run", action="store_true", help="Print data, skip PDF")
    args = parser.parse_args()

    # Resolve month
    if args.month:
        year, month = map(int, args.month.split("-"))
    else:
        today = datetime.now()
        first_of_current = datetime(today.year, today.month, 1)
        prev = first_of_current - timedelta(days=1)
        year, month = prev.year, prev.month

    since, until = get_month_range(year, month)
    print(f"\nGenerating RBD Marketing Recap: {datetime(year, month, 1).strftime('%B %Y')}")
    print(f"Date range: {since} → {until}\n")

    config  = load_config()
    secrets = decrypt_secrets()

    print("Fetching Meta Ads spend...")
    meta = fetch_meta_spend(secrets, since, until)

    print("Fetching Google Ads spend...")
    google = fetch_google_spend(secrets, since, until)

    print("Reading social tracker CSV...")
    social = fetch_social_data(datetime(year, month, 1).strftime("%B %Y"))

    template_vars = build_template_vars(
        year, month, meta, google, social, config,
        args.event, args.next_theme
    )

    print("\nTemplate variables:")
    for k, v in template_vars.items():
        print(f"  {k}: {v}")

    if args.dry_run:
        print("\nDry run — skipping PDF generation.")
        return

    html = fill_template(TEMPLATE_PATH, template_vars)

    output_dir = Path(os.path.expanduser(config["output_dir"]))
    output_dir.mkdir(parents=True, exist_ok=True)
    month_slug = datetime(year, month, 1).strftime("%Y-%m")
    output_path = output_dir / f"RBD_Marketing_Recap_{month_slug}.pdf"

    print(f"\nGenerating PDF → {output_path}")
    html_to_pdf(html, output_path)
    print(f"Done: {output_path}")
    print(f"OUTPUT_PATH={output_path}")   # parsed by bot guide


if __name__ == "__main__":
    main()

Step 2: Dry-run test

cd ~/ai-projects-local/mission-control && \
python3 scripts/marketing_recap_generator.py --month 2026-04 --dry-run

Expected: prints Meta spend ~$X, Google spend ~$X, social data from CSV, template vars table. No errors.

Step 3: Full run with PDF output

python3 scripts/marketing_recap_generator.py --month 2026-04 \
  --event "H&H Chicago Trade Show" \
  --next-theme "Mothers, Makers & Memories"

Expected: PDF at ~/ai-projects/mission-control/reports/marketing-recaps/RBD_Marketing_Recap_2026-04.pdf

Step 4: Open PDF and verify it looks correct

open ~/ai-projects/mission-control/reports/marketing-recaps/RBD_Marketing_Recap_2026-04.pdf

Expected: One-pager renders with real data filled in, no {VARNAME} placeholders visible.


Task 4: Create Bot Guide

Files:

  • Create: ~/ai-projects-local/mission-control/scripts/bot-guides/marketing-recap.md

Step 1: Write the guide

# Marketing Recap Guide

## Trigger phrases
"monthly recap", "marketing recap", "marketing report", "send me the recap",
"generate the recap", "marketing one pager", "monthly marketing", "recap"

## Who can use this
All users (admin and readonly). Mary is the primary user.

## What this does
Generates the RBD monthly marketing one-pager as a PDF and emails it.
Covers: Meta Ads spend, Google Ads spend, social followers, email list size.

## How to run

### Default (previous full month)
```bash
python3 ~/ai-projects-local/mission-control/scripts/marketing_recap_generator.py

Specific month

python3 ~/ai-projects-local/mission-control/scripts/marketing_recap_generator.py --month 2026-04

With event + theme context (if user provides them)

python3 ~/ai-projects-local/mission-control/scripts/marketing_recap_generator.py \
  --month 2026-04 \
  --event "H&H Chicago Trade Show" \
  --next-theme "Mothers, Makers & Memories"

Output

The script prints a line like: OUTPUT_PATH=/Users/colegorringe/ai-projects/mission-control/reports/marketing-recaps/RBD_Marketing_Recap_2026-04.pdf

Use that path to email the file.

Email the PDF

python3 ~/ai-projects-local/mission-control/scripts/send_email.py \
  --to [user_email] \
  --subject "RBD Marketing Recap — April 2026" \
  --body "Your RBD monthly marketing recap is attached." \
  --attachment [OUTPUT_PATH]

Example Telegram reply

"Done — your April 2026 marketing recap is on its way to mary@alpineanalytica.com."

Notes

  • Generation takes ~30-60 seconds (API calls + wkhtmltopdf)
  • If Google Ads data is unavailable, it gracefully shows $0 for Google with a warning
  • PDF goes to ~/ai-projects/mission-control/reports/marketing-recaps/
  • If user asks for a specific month, pass --month YYYY-MM

**Step 2: Verify the guide appears in the bot's guide directory**
```bash
ls ~/ai-projects-local/mission-control/scripts/bot-guides/

Expected: marketing-recap.md listed alongside other guide files.


Task 5: Add Mary to Telegram Allowlist

Files:

  • Modify: ~/ai-projects-local/mission-control/scripts/telegram_watcher.py

Step 1: Mary messages the bot Have Mary open Telegram, find the bot, and send any message (e.g. "hi").

Step 2: Get her user ID from the log

tail -50 ~/cron-logs/telegram-watcher.log | grep -i "unknown\|not in ALLOWED\|user_id\|from"

Expected: a line showing Mary's numeric Telegram user ID.

Step 3: Add Mary to ALLOWED_USERS

In telegram_watcher.py, find:

ALLOWED_USERS = {
    5876831456: {"name": "Cole",  "role": "admin",    "email": "cole@alpineanalytica.com"},
    8652258902: {"name": "Caleb", "role": "readonly", "email": "caleb@alpineanalytica.com"},
    # Mary — add user ID once she messages the bot
    # 123456789: {"name": "Mary", "role": "readonly", "email": ""},

Replace with (using Mary's actual user ID):

ALLOWED_USERS = {
    5876831456: {"name": "Cole",  "role": "admin",    "email": "cole@alpineanalytica.com"},
    8652258902: {"name": "Caleb", "role": "readonly", "email": "caleb@alpineanalytica.com"},
    MARY_USER_ID: {"name": "Mary", "role": "readonly", "email": "mary@alpineanalytica.com"},

Step 4: Restart the Telegram watcher

pkill -f telegram_watcher.py
sleep 2
nohup python3 ~/ai-projects-local/mission-control/scripts/telegram_watcher.py >> ~/cron-logs/telegram-watcher.log 2>&1 &

Step 5: Verify Mary can now send a message and get a reply Have Mary send "hi" — she should get a response instead of being ignored.


Task 6: End-to-End Test

Step 1: Mary sends a recap request on Telegram "can you send me the monthly marketing recap"

Step 2: Verify the bot picks it up

tail -f ~/cron-logs/telegram-watcher.log

Expected: lines showing Processing: can you send me the monthly... then marketing-recap guide matched, then marketing_recap_generator.py running.

Step 3: Verify the PDF arrives in Mary's email Mary checks her inbox for an email with the PDF attachment.

Step 4: Verify the PDF content Open the attached PDF — all {VARNAME} placeholders replaced with real data, layout looks clean.


Execution Log

(update as tasks complete)


Known Gaps / Future Work

  • Campaign Highlights section is static in the template — future enhancement: pull top 3 Meta campaigns by ROAS and auto-populate
  • Best Performing Ads appendix (page 2) has ad images embedded in the HTML — these will remain as the April example until we build an ad image fetcher
  • YTD pacing is calculated as months_elapsed / 12 (time-based) rather than actual_YTD_spend / annual_budget — fix in v2 once we confirm Google Ads data is pulling correctly
  • Mary's Telegram user ID is unknown until she messages the bot — Task 5 is blocked on this

Blockers

None currently. Task 5 (adding Mary) requires her to message the bot first.