# /monthly-report

> When the user invokes the skill with extra instructions, translate the request into one or more `--no-*` flags before running the script.


# Monthly Performance Report Generator

## Triggers

- `/monthly-report ACME Agency` — generate the **full** report for last month
- `/monthly-report ACME Agency --month 2` — full report, 2 months ago
- `/monthly-report ACME Agency --since 2026-02-19 --until 2026-03-31` — **custom date range** (e.g. "since campaign launch"). Hero badge shows "19. veljače – 31. ožujka 2026" (HR) or "19. februar – 31. mart 2026" (BS). File is saved as `<id>.pdf` (separate from the standard monthly file so they don't overwrite each other).
- `/monthly-report` — will prompt for client name

### Natural-language → CLI flag mapping

When the user invokes the skill with extra instructions, translate the request into one or more `--no-*` flags before running the script. Default is **always the full report** — only suppress what the user explicitly asks to skip.

| User says | Add to command |
|-----------|----------------|
| "skip search terms", "no Google keywords", "don't show search terms", "ne pokazuj search termove" | `--no-search-terms` |
| "no Facebook", "skip Meta", "bez Mete", "don't include Facebook" | `--no-meta` |
| "no Google", "skip Google", "bez Googlea" | `--no-google` |
| "skip GHL", "no CRM", "bez GHL-a" | `--no-ghl` |
| "skip ClickUp", "no work summary", "bez sto smo napravili" | `--no-clickup` |

Examples:
- `/monthly-report ACME Agency, skip search terms` → `node ... --client "ACME Agency" --no-search-terms`
- `/monthly-report ACME Agency, no Google` → `node ... --client "ACME Agency" --no-google`
- `/monthly-report ACME Agency, skip Meta and search terms` → `node ... --client "ACME Agency" --no-meta --no-search-terms`

If the user only says the client name with no qualifiers, run the **full** report — Meta + Google + search terms + GHL + ClickUp + everything.

## What this skill does

1. Identifies the client from the argument, looks up in `clients.json`
2. Auto-discovers any missing config (Meta account, Google Ads ID, Drive folder, Slack channel, GHL location)
3. Fetches data from all available sources in parallel
4. Generates a branded multi-page PDF report
5. Uploads PDF to Google Drive and posts summary to Slack
6. Reports any missing data sources in the Slack message

## Report sections (in order)

| Section | Source | Notes |
|---------|--------|-------|
| Hero + period badge | clients.json `reference_assets.logo` | Client logo + "Izvjestaj o rezultatima" |
| GHL lead overview | GHL Opportunities API | Big lead number (GHL is source of truth). **Top section is intentionally lead-count-only** — no combined spend, no combined CPL, no per-platform spend cards. Per-platform spend and CPL are shown in the Meta and Google sections below. |
| Meta KPIs (5 cards) | Meta Insights API (campaign level) | Spend, clicks, impressions, leads, **Cijena po leadu** (no CTR). **Auto-splits into TWO sub-sections** (Lead generation + Prodaja/kupovine) when the client ran both `OUTCOME_LEADS` and `OUTCOME_SALES` campaigns in the same period — purchase sub-section shows Kupovine + Cijena po kupovini instead of Leadovi + CPL |
| Google KPIs (5 cards) | Google Ads API (campaigns) | Spend, clicks, impressions, **conversions OR "Kvalifikovani leadovi"** (when `<id>` is set), **Cijena po leadu** |
| Demographics charts | Meta breakdowns + Google gender_view/device | Gender + device bar charts, both platforms side by side |
| Top 3 Meta ads | Meta Insights API (ad level) + creatives | Real creative thumbnail (uses `image_url`, not `thumbnail_url`), **anonymized as "Oglas 1 / Oglas 2 / Oglas 3" by spend rank** (raw ad names like "DON'T LAUNCH" / "test v2" never leak to clients), spend, clicks, leads, CPL — ranked by spend |
| Top 10 Google search terms | Google Ads search_term_view | Table with impressions, clicks, cost, conversions — sorted by cost. Auto-skipped when `<id>: true` or `--no-search-terms` |
| Landing page screenshots | Playwright + Firecrawl | Up to 3 pages, viewport screenshot + content analysis. URLs filtered through `landing_page_excludes` |
| Drive creatives | Google Drive API | **Up to 9 images** from Design → Year → Month folder, with Drive links. **No file names shown** (clients name files inconsistently) |
| **GHL CRM pipeline** | GHL Pipelines + Opportunities API | Stage breakdown for leads created in the period, top stages as KPI cards + full list, won count + win rate. **Filter is `createdAt`, NOT `lastStageChangeAt`** |
| Work summary (ClickUp) | ClickUp API | **Detected work categories + always-on items** (no counts, no raw task names) + closing sentence emphasizing continuous work |
| Conclusion | Auto-generated | Croatian summary of leads, spend, CPL |
| Next steps | Auto-generated or custom | Up to **6** smart defaults based on active platforms, or custom via `--next-steps` |

## Per-client config flags (clients.json)

Persistent overrides set in each client's entry in [clients.json](../../ACME Agency/clients/clients.json). All optional. Use these for client-specific quirks that should apply to **every** future run.

| Flag | Type | Effect |
|------|------|--------|
| `language` | `"hr"` or `"bs"` | Language for the month label in the hero badge. Default `"hr"` shows Croatian months (Siječanj, Veljača, Ožujak...). Set to `"bs"` for Bosnian/Serbian clients to show Januar, Februar, Mart, etc. |
| `ad_account` | string OR array | Meta ad account ID. **Pass an array to combine multiple accounts** into one report (e.g. during a transition between two accounts). Totals, top ads, and demographics aggregate across all accounts. Single string still works for the common case. |
| `<id>` | string | Optional override for the Drive folder where creatives live. Set this when the auto-discovery (search top-level for "Design"/"Dizajn"/"Kreativ" folder) doesn't find the right one — points the recursive month walker at a specific folder ID instead. |
| `ad_account_primary` | string | Optional. When `ad_account` is an array, this is the long-term canonical account — informational only, not used by report logic but kept so other scripts know which one to write to. |
| `<id>` | string | Counts only this Google Ads conversion action by name (e.g. `"Qualifed Lead"`). Avoids double-counting when both "Submit lead form" and "Qualifed Lead" are tracked. The Google KPI label switches to "Kvalifikovani leadovi". |
| `<id>` | boolean | Splits Google Ads conversions into "real leads" vs "Lokalne akcije" (Google Maps "Get directions" clicks). When true, fetches `segments.<id>` breakdown and groups any action matching `/local action\|direction/i` into the directions bucket. The headline "Leadovi" KPI counts only real leads (form / call / qualified / GHL events); a separate "Lokalne akcije" KPI card shows directions count, and an explanatory note appears below the Google KPI strip. Use for clinics / brick-and-mortar businesses (e.g. ACME Agency) where Google Maps directions clicks are tracked as conversions but aren't real form leads — without this, a single campaign can show 239 "conversions" that are actually 3 leads + 236 directions clicks. |
| `<id>` | boolean | Skips the entire Google search terms section. Use when terms are noisy, sensitive, or off-brand. |
| `landing_page_excludes` | string[] | Substring matches; URLs containing any are filtered from the landing-page screenshots. Use when Google Ads final URLs include marketing site pages that aren't real funnel landings. |
| `ghl_location_id` | string | GHL Location ID — wires the GHL lead overview, pipeline section, and ±10% variance check. |
| `ghl_api_key_env` | string | Either an env var name (e.g. `"<id>"`, preferred — keeps tokens out of git) OR a literal `pit-...` token (legacy from registry sheet sync). [`resolveGhlConfig()`](../../ACME Agency/scripts/lib/ghl.mjs) handles both. |

## Runtime overrides (CLI flags)

Per-run flags. Additive on top of clients.json defaults — if a flag is set in clients.json AND on the CLI, the CLI just reinforces the skip. If only clients.json says skip, it still skips.

| Flag | Effect |
|------|--------|
| `--no-meta` | Skip Meta KPI section, top ads, and Meta demographics |
| `--no-google` | Skip Google KPI section, search terms, and Google demographics |
| `--no-search-terms` | Skip Google search terms section only (Google KPIs + demographics still show) |
| `--no-ghl` | Skip GHL lead overview and GHL pipeline section |
| `--no-clickup` | Skip "Što smo napravili" section |
| `--month N` | Report on N months ago (default: 1 = last calendar month) |
| `--since YYYY-MM-DD --until YYYY-MM-DD` | **Custom date range** — overrides `--month`. Must be used together. File is saved as `<id>.pdf`, Slack title becomes "Izvjestaj" (not "Mjesecni izvjestaj"), and the hero badge shows a range label in the client's language. |
| `--html-only` | Generate HTML pACME Agencyw only — no PDF, no Drive upload, no Slack |
| `--next-steps "..."` | Custom newline-separated next steps (overrides auto-generated) |
| `--all` | Run for every eligible client |

## Quality controls (already in code)

These are guardrails baked into the pipeline — listed here so future runs trust them and don't reimplement.

- **Meta lead counting uses precedence, not sum.** `LEAD_ACTION_PRIORITY` in [meta_ads.mjs](../../ACME Agency/scripts/lib/meta_ads.mjs) returns the first matching action type (`onsite_conversion.lead_grouped` → `offsite_conversion.fb_pixel_lead` → `leadgen_grouped` → `lead`). Never sums across types — Meta's `lead` field is already the aggregate. `extractLeads()` enforces this.
- **GHL is the source of truth for lead counts.** The pipeline cross-checks Meta+Google totals against GHL; deviations >±10% in either direction print `[WARN] Lead variance ...` to the console. See [<id>.md](../../../.claude/projects/<id>/memory/<id>.md).
- **Drive uploads upsert by filename.** [`uploadOrUpdateFile()`](../../ACME Agency/scripts/lib/google_drive.mjs) finds existing reports by name and PATCHes the content — same fileId, same shareable link across regenerations. Slack links don't go dead when you re-run.
- **GHL pipeline filter is `createdAt`.** Only leads CREATED in the reporting month are included, regardless of their current pipeline stage. See `getOpportunities()` in [ghl.mjs](../../ACME Agency/scripts/lib/ghl.mjs).
- **Zero-spend platforms are skipped automatically.** If Meta has 0 spend AND 0 impressions in the period, `buildMetaSection()` returns null and the entire Meta section (KPIs, demographics, top ads) is omitted from the report. Same for Google. The Pregled rezultata header drops the corresponding "Meta potrošnja" / "Google potrošnja" card too. Console logs `Meta: no spend in period, skipping section`.
- **Meta ad names are anonymized in client-facing output.** `buildTopAdsFromMeta()` replaces raw ad names with `Oglas 1 / Oglas 2 / Oglas 3` by spend rank before they hit the PDF or Slack. Paradox uses internal naming conventions like "DON'T LAUNCH", "test v2", "STOP IT - broken tracking" that should never reach a client — the thumbnail + metrics carry all the information the client needs. Raw names are still preserved upstream (`fetchAdCreatives` → `c.adName`) if any other tool needs them. Rank is stable: "Oglas 1" is always the highest-spend ad after re-sorting matched creatives against the spend-sorted `top` list.
- **Meta auto-splits when lead-gen and purchase campaigns coexist.** [`getCampaignObjectives()`](../../ACME Agency/scripts/lib/meta_ads.mjs) fetches each campaign's `objective` field and classifies into `lead` (`OUTCOME_LEADS`), `purchase` (`OUTCOME_SALES`), or `other`. When BOTH lead and purchase buckets have spend in the same period, [`buildMetaSection()`](../../ACME Agency/scripts/lib/report_data.mjs) returns a `byObjective` object and the template renders TWO Meta sub-sections — "Meta oglašavanje — Lead generation" with leads + CPL and "Meta oglašavanje — Prodaja / kupovine" with purchases + cost per purchase. Pregled rezultata adds a "Cijena po kupovini" card and excludes purchase spend from headline CPL math (lead-only spend / GHL leads). Single-objective clients see no change. Purchase counts use [`extractPurchases()`](../../ACME Agency/scripts/lib/meta_ads.mjs) with `<id>` precedence (same dedupe logic as leads).
- **Drive creatives use a recursive month walker.** [`<id>()`](../../ACME Agency/scripts/lib/report_data.mjs) walks the Design folder tree (max depth 5) looking for any subfolder whose name matches the target month (HR / BS / EN / numeric). Year folders are filtered to the target year; everything else (Country, Language, "Kreative" wrappers) is descended into. Handles all known client folder shapes: `Design → Month`, `Design → Year → Month`, `Design → Country → Year → Month → Date subfolder`. For odd cases, set `<id>` to point at a specific folder ID directly.

## How to run

```bash
# Full execution — PDF + Drive + Slack (default = full report)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency"

# Different month (2 months ago)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --month 2

# Custom next steps
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --next-steps "Testirati nove video oglase\nSkalirati kampanju za implantologiju"

# HTML pACME Agencyw only — no PDF, no Drive, no Slack
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --html-only

# Suppress sections at runtime (one-off, no clients.json edit needed)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-search-terms
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-google
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-meta --no-search-terms

# All eligible clients
node ACME Agency/scripts/monthly_report.mjs --all
```

## Step-by-step

### Phase 1 — Identify client

1. Read `ACME Agency/clients/clients.json`
2. Look up the client name (case-insensitive, fuzzy match on `full_name` or key)
3. If not found, list available clients and ask the user

### Phase 1.5 — Auto-cascade from registry sheet (built into the script)

The script now runs `ensureClientHydrated()` automatically before every report. If any of these fields are missing in the client's `clients.json` entry, it calls `syncClientToJson()` to backfill from the [team-maintained registry sheet](https://docs.google.com/spreadsheets/d/<id>/edit) and re-reads the entry:

`ad_account`, `google_ads_id`, `ghl_location_id`, `ghl_api_key_env`, `drive_folder_id`, `slack_channel`, `clickup_list_id`

**Why:** the team maintains the sheet as the canonical source of truth. Whenever someone adds a new ad account ID, GHL location, or ClickUp list to the sheet, the next monthly report run picks it up and writes it into `clients.json` — no manual sync needed. Console logs `[hydrate]` lines so you can see it happen.

This is part of the 3-step client lookup cascade documented in [ACME Agency/CLAUDE.md](../../ACME Agency/CLAUDE.md): clients.json → registry sheet → API discovery (last resort).

### Phase 2 — Auto-discover missing config (rare fallback)

If after the sheet cascade some fields are STILL missing (because the sheet doesn't have them either), discover from the actual APIs and update both the sheet and `clients.json`. Most of the time you can skip this phase entirely — the cascade handles it.

**Step 2.1: Meta `ad_account`**
If null:
```bash
node -e "
import { metaGet } from './ACME Agency/scripts/lib/meta_ads.mjs';
const r = await metaGet('<id>/owned_ad_accounts', { fields: 'name,account_id', limit: 200 });
for (const a of r.data) console.log(a.account_id, a.name);
"
```
Find the account matching the client name (case-insensitive). Update clients.json with `"ad_account": "act_XXXXX"`. If no match → proceed without Meta.

**Step 2.2: Google `google_ads_id`**
If null:
```bash
node -e "
import { getAccessToken, searchQuery } from './ACME Agency/scripts/lib/google_ads.mjs';
const token = await getAccessToken();
const r = await searchQuery('<id>', 'SELECT customer_client.id, customer_client.descriptive_name FROM customer_client WHERE customer_client.manager = false', token);
for (const c of r) console.log(c.customerClient.id, c.customerClient.descriptiveName);
"
```
Find matching account. Update clients.json. If no match → proceed without Google.

**Step 2.3: Google Drive `drive_folder_id`**
If null:
```bash
gws drive files list --query "name contains '<ClientName>' and mimeType='application/vnd.google-apps.folder'" --fields "files(id,name)"
```
Look for folder under `Klijenti/`. Update clients.json. If no match → skip Drive upload.

**Step 2.4: Slack `slack_channel`**
If null: search Slack channels for client name match. Update clients.json. If no match → skip Slack.

**Step 2.5: GHL `ghl_location_id`**
If `ghl_api_key_env` exists but `ghl_location_id` is missing: ask the user for the location ID (visible in GHL URL: `app.your-domain.example/v2/location/<ID>/dashboard`). You cannot discover it from the API token alone. If missing → proceed without GHL, note in Slack message.

### Phase 3 — Run the script

```bash
node ACME Agency/scripts/monthly_report.mjs --client "<ClientName>"
```

The script handles everything: data fetching (parallel), HTML generation, PDF rendering, Drive upload, and Slack posting. Takes 1-3 minutes.

### Phase 4 — Verify and report

After the script completes, check console output for:
- `HTML saved:` — confirms HTML generated
- `PDF saved:` — confirms PDF rendered
- `Drive:` — confirms upload with link
- `Slack: posted to` — confirms Slack message
- Any `error` lines for failed sections

Report to user: PDF path, Drive link, Slack channel, and any missing data sources.

### Phase 5 — (Optional) Deeper agent-based analysis

`monthly-report` is intentionally a **monolithic autonomous pipeline** — the script handles its own data fetching, analysis, PDF rendering, Drive upload, and Slack post. Unlike `/meta-ads-analyze` and `/google-ads-optimize`, monthly-report does NOT route through `analyst` + `slack-reporter` by default, because the script already produces a finished deliverable.

**When to invoke the analyst on top of monthly-report's output:**
- The user asks for a deeper read on a specific anomaly the PDF surfaced
- A multi-month trend question that the script's per-month report can't answer
- A "what changed?" analysis comparing this month to last

**How:** Read the PDF (or the script's intermediate JSON if exposed via `--save-data`), then invoke the `analyst` subagent with the data. Hand the analyst's findings to `slack-reporter` for posting as a thread reply on the original monthly-report message.

This is opt-in. The default flow stays as Phase 1-4 above.

### Diagnostician hook

If a data source fails twice with the same error during the script run (Meta `OAuthException`, Google Ads `<id>`, GHL 401, ClickUp 403, Drive 404 on a known folder), invoke the `diagnostician` subagent before retrying. These errors almost always have UI/permission/token-rotation root causes that loops cannot fix.

## Data pipeline details

### Landing page discovery
URLs are collected from two sources:
1. **Meta Ads** — `object_story_spec.link_data.link` from ad creatives
2. **Google Ads** — `ad_group_ad.ad.final_urls` from all ads that ran in the period

Platform redirects are auto-filtered: `fb.me`, `facebook.com`, `instagram.com`, `google.com`, `l.facebook.com`, `goo.gl`. These are not real landing pages.

**Fallback logic:** If Meta only has lead forms (URLs are all `fb.me`), Google Ads final URLs are used as the primary source. Most lead gen clients have real landing pages in Google Ads even when Meta uses native forms.

Up to 3 unique URLs are screenshotted (Playwright, 1280x900 viewport) and analyzed (Firecrawl for title + description).

### Drive creative discovery
Navigates: client `drive_folder_id` → finds folder matching "design"/"dizajn"/"kreativ" → year subfolder (e.g. "2026") → month subfolder (matches Croatian/English/Bosnian/numeric names like "03.Mart", "Ozujak", "March").

**Subfolder recursion:** If the month folder contains subfolders instead of images (e.g. "Oglasi 1", "Oglasi 2"), it recurses one level to find images inside.

Downloads **up to 9 images**, resizes to 400px, converts to base64, and includes Drive view links. **File names are intentionally not displayed** in the report — clients name files inconsistently (1.png, 2.png, banner_v3.jpg) and surfacing them is noise.

### ClickUp work areas summary

Finds the client's ClickUp list in Klijenti space (6748685) by normalized name matching (strips spaces: "ACME Agency" matches "ACME Agency Oftamologija"). Pulls closed tasks for the reporting period — but **does NOT show counts or raw task names** to the client.

Instead, the report shows:

1. **Detected work categories** (only those with at least one matching task that month) — campaigns optimization, creative preparation, copy, landing pages, Google Ads, Meta Ads, CRM/automation, client communication.
2. **Always-on items** that are present every month regardless of ClickUp activity — daily monitoring, performance analysis, budget rebalancing, regular communication, minor tweaks/tests.
3. **Closing sentence** emphasizing continuous behind-the-scenes work.

The result always looks like 8-10 substantive items even on light months. Clients never see exact task counts (which makes the team look slower than they are on quiet months) or raw ClickUp task names (which leak internal jargon).

### Next steps
If `--next-steps "text"` is provided, uses that custom text (newline-separated).

If not provided, auto-generates **up to 6** smart steps based on active platforms:
- Meta active → new creative angles, A/B testing hooks, scaling winners, retargeting
- Google active → search terms ACME Agencyw, negative keyword expansion, RSA testing, extensions
- Landing pages found → A/B testing hero/CTA, Hotjar/Clarity session analysis
- GHL active → follow-up sequence optimization, won-lead pattern analysis
- General → ad fatigue refresh, strategy for next period

### Demographics
- **Meta:** `fetchInsights()` with `breakdowns: 'gender'` and `breakdowns: 'device_platform'`
- **Google:** `gender_view` GAQL query + `getDeviceBreakdown()` (aggregated across campaigns)
- Rendered as CSS horizontal bar charts, green for Meta, blue for Google

### Optimization
- Meta ad-level data is fetched once and shared between landing page URL extraction and top ads section (no duplicate API calls)
- Google access token is fetched once and reused for all Google API calls
- All extended data (demographics, top ads, search terms, ClickUp) runs in parallel with `.catch()` — failures are non-fatal

## Key files

| File | Purpose |
|------|---------|
| `ACME Agency/scripts/monthly_report.mjs` | Main orchestrator — CLI, PDF render, Drive upload, Slack |
| `ACME Agency/scripts/lib/report_data.mjs` | Data fetching — Meta, Google, GHL, ClickUp, Drive creatives, landing pages |
| `ACME Agency/scripts/lib/report_template.mjs` | HTML template — all sections, CSS, bar charts, multi-page layout |
| `ACME Agency/scripts/lib/pdf_render.mjs` | Playwright PDF renderer with branded header/footer |
| `ACME Agency/scripts/lib/meta_ads.mjs` | Meta Ads API — insights, creatives, demographics |
| `ACME Agency/scripts/lib/google_ads.mjs` | Google Ads API — campaigns, search terms, gender/device breakdown |
| `ACME Agency/scripts/lib/ghl.mjs` | GHL Opportunities API (leads) |
| `ACME Agency/scripts/lib/clickup.mjs` | ClickUp API — space lists, closed tasks |
| `ACME Agency/scripts/lib/google_drive.mjs` | Drive API — folder navigation, upload, image download |
| `ACME Agency/scripts/lib/firecrawl.mjs` | Landing page content scraping |
| `ACME Agency/clients/clients.json` | Client registry |

## Important rules

- **Default is the FULL report.** Meta + Google + search terms + GHL + ClickUp + everything. Suppress sections only when the user explicitly asks via `--no-*` flags or when `clients.json` flags are set.
- **Default period is always last calendar month** (1st to last day) unless `--month N` is specified
- **Never ask the user for IDs** — always look them up from `clients.json` or auto-discover
- **Update clients.json** with any discovered IDs so future runs skip discovery
- **Proceed without missing sources** — the report gracefully skips sections when data is unavailable (template short-circuits on null)
- **Slack message in Bosnian** — no emojis, per SLACK_REPORT_STANDARD.md
- **Always share the Drive link** immediately after upload. Drive uses `uploadOrUpdateFile()` so the link is stable across regenerations.
- **GHL is source of truth for lead counts.** Console will print `[WARN] Lead variance N% ...` if Meta+Google deviates from GHL by more than ±10% — investigate tracking before sending the report to the client.
- **Landing page fallback** — if Meta URLs are all platform redirects (fb.me, etc.), Google Ads final URLs are used
- **Drive subfolder recursion** — if month folder has subfolders (Oglasi 1, etc.), check inside them for images
- **ClickUp name normalization** — strip spaces before matching (handles "ACME Agency" vs "ACME Agency")
- **Next steps** — auto-generated (up to 6) based on active platforms unless custom text provided via `--next-steps`
