@ads-repo/meta-creative-buckets 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +76 -0
- package/SKILL.md +231 -0
- package/buckets.config.example.json +8 -0
- package/env.example +29 -0
- package/install-to-claude.js +36 -0
- package/package.json +40 -0
- package/scripts/build-buckets-matrix.cjs +1367 -0
- package/scripts/config.cjs +133 -0
- package/scripts/encrypt-dashboard.cjs +241 -0
- package/scripts/fetch-buckets-data.cjs +398 -0
- package/scripts/fetch-creative-meta.cjs +172 -0
- package/scripts/optional/README.md +65 -0
- package/scripts/optional/extract-promo-labels.cjs +78 -0
- package/scripts/optional/extract-thumbs.cjs +169 -0
|
@@ -0,0 +1,1367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a self-contained MATRIX dashboard that plots Meta creatives on a Spend × CRR
|
|
5
|
+
* quadrant grid:
|
|
6
|
+
*
|
|
7
|
+
* CRR bad (high) │ Wait │ Kill
|
|
8
|
+
* ├───────────┼──────────
|
|
9
|
+
* CRR good (low) │ NotData │ Scale
|
|
10
|
+
* └──────────────────────
|
|
11
|
+
* low spend enough spend
|
|
12
|
+
*
|
|
13
|
+
* The Y axis is
|
|
14
|
+
*
|
|
15
|
+
* CRR = spend ÷ purchase revenue, as % (7d click; inverse ROAS, CRR 40% == ROAS 2.5)
|
|
16
|
+
*
|
|
17
|
+
* LOWER is better, so an ad sits lower on the grid the better it earns.
|
|
18
|
+
*
|
|
19
|
+
* The spend gate is its own currency slider, independent of the target: a multiple of a
|
|
20
|
+
* percentage target would be meaningless. The whole LEFT column (under the gate) is
|
|
21
|
+
* "Not enough data" — an ad there is not judged on CRR yet. Cards are bubbles inside the
|
|
22
|
+
* quadrants; clicking a quadrant (or its header) filters the list underneath to that bucket.
|
|
23
|
+
*
|
|
24
|
+
* Defaults are seeded FROM THE DATA (see below), not hardcoded, so the target line starts
|
|
25
|
+
* where the account actually sits rather than at an invented benchmark.
|
|
26
|
+
*
|
|
27
|
+
* Currency is read off the fetched JSON — never assumed, and never inferred from the account
|
|
28
|
+
* name: an account named "… CZ" may still bill in EUR, where a hardcoded "Kč" would be wrong
|
|
29
|
+
* by a factor of ~25.
|
|
30
|
+
*
|
|
31
|
+
* Output: data/meta-ads/<client>/buckets-matrix.html (opens in browser).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { execSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
const cfg = require('./config.cjs');
|
|
39
|
+
|
|
40
|
+
const MA_DIR = cfg.dataDir;
|
|
41
|
+
const HIST_DIR = cfg.historyDir;
|
|
42
|
+
const LATEST = cfg.latestFile;
|
|
43
|
+
|
|
44
|
+
if (!fs.existsSync(LATEST)) {
|
|
45
|
+
console.error(`Error: ${LATEST} not found — run fetch-buckets-data.cjs first.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const cur = JSON.parse(fs.readFileSync(LATEST, 'utf-8'));
|
|
49
|
+
|
|
50
|
+
// Ad and campaign names are user-authored in Ads Manager, so they can contain anything.
|
|
51
|
+
// Inside a <script> block the parser looks for "</script>" in the raw text, ahead of JSON
|
|
52
|
+
// syntax — an ad named "sale </script>" would end the block early and blank the page. The
|
|
53
|
+
// escapes below are invisible to JSON.parse and keep the payload inert.
|
|
54
|
+
function jsonForScript(value) {
|
|
55
|
+
return JSON.stringify(value)
|
|
56
|
+
.replace(/</g, '\\u003c')
|
|
57
|
+
.replace(/>/g, '\\u003e')
|
|
58
|
+
// U+2028/U+2029 are literal line breaks in JS source but legal inside a JSON string
|
|
59
|
+
.replace(/\u2028/g, '\\u2028')
|
|
60
|
+
.replace(/\u2029/g, '\\u2029');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Account names come from the Meta API, so they are interpolated into the HTML escaped.
|
|
64
|
+
function esc(s) {
|
|
65
|
+
return String(s == null ? '' : s)
|
|
66
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Optional logos, inlined as data URIs so the dashboard stays a single portable file (it is
|
|
71
|
+
// opened straight off disk / emailed around, so an external <img src> would break).
|
|
72
|
+
// Set `logo` / `agencyLogo` in buckets.config.json to project-root-relative image paths.
|
|
73
|
+
// Nothing configured, or a missing file, renders no logo rather than a broken image.
|
|
74
|
+
function dataUri(relPath) {
|
|
75
|
+
if (!relPath) return null;
|
|
76
|
+
const p = path.isAbsolute(relPath) ? relPath : path.join(process.cwd(), relPath);
|
|
77
|
+
if (!fs.existsSync(p)) {
|
|
78
|
+
console.warn(` ⚠ logo not found: ${relPath} — rendering without it`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
82
|
+
'.svg': 'image/svg+xml', '.webp': 'image/webp' }[path.extname(p).toLowerCase()];
|
|
83
|
+
if (!mime) {
|
|
84
|
+
console.warn(` ⚠ unsupported logo format: ${relPath} — rendering without it`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return `data:${mime};base64,${fs.readFileSync(p).toString('base64')}`;
|
|
88
|
+
}
|
|
89
|
+
const LOGO_CLIENT = dataUri(cfg.logo);
|
|
90
|
+
const LOGO_AGENCY = dataUri(cfg.agencyLogo);
|
|
91
|
+
|
|
92
|
+
// Header label: config override, then the name stored by the fetch, then the account id.
|
|
93
|
+
const ACCOUNT_LABEL = cfg.accountName || cur.accountName || cur.account;
|
|
94
|
+
|
|
95
|
+
// Currency symbol straight from the account, via the fetched JSON. Never inferred from the
|
|
96
|
+
// account name — an account named "… CZ" may still bill in EUR.
|
|
97
|
+
const CURRENCY = cur.currency || 'EUR';
|
|
98
|
+
const SYMBOL = { EUR: '€', CZK: 'Kč', USD: '$', GBP: '£' }[CURRENCY] || (CURRENCY + ' ');
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Previous snapshot -> day-over-day CRR movement arrows in the table.
|
|
102
|
+
// Picks the newest dated snapshot that is NOT the current one.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
function prevSnapshot() {
|
|
105
|
+
if (!fs.existsSync(HIST_DIR)) return null;
|
|
106
|
+
const files = fs.readdirSync(HIST_DIR).filter(f => /^\d{8}-buckets\.json$/.test(f)).sort();
|
|
107
|
+
const curName = `${cur.endDate.replace(/-/g, '')}-buckets.json`;
|
|
108
|
+
const older = files.filter(f => f < curName);
|
|
109
|
+
if (!older.length) return null;
|
|
110
|
+
try { return JSON.parse(fs.readFileSync(path.join(HIST_DIR, older[older.length - 1]), 'utf-8')); }
|
|
111
|
+
catch (e) { return null; }
|
|
112
|
+
}
|
|
113
|
+
const prev = prevSnapshot();
|
|
114
|
+
const prevCrr = {};
|
|
115
|
+
if (prev) for (const a of prev.ads) prevCrr[a.ad_id] = a.crr;
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// OPTIONAL promo labels, joined by ad_id. Not part of the standard workflow: the labels
|
|
119
|
+
// come from a separate creative classification, so most setups will not have this file
|
|
120
|
+
// and the dashboard simply hides the promo filter.
|
|
121
|
+
//
|
|
122
|
+
// Format is a flat JSON map, ad_id -> one of:
|
|
123
|
+
// "Promo - main message" | "Promo - secondary message" | "Non-promo"
|
|
124
|
+
// written to data/meta-ads/<client>/promo-labels.json.
|
|
125
|
+
//
|
|
126
|
+
// When the file IS present, unlabelled ads are DROPPED rather than folded into Non-promo —
|
|
127
|
+
// unknown is not the same as "no offer" — so every bubble on the page is classified. That
|
|
128
|
+
// makes coverage partial by design; the build prints how many ads were dropped.
|
|
129
|
+
//
|
|
130
|
+
// UNCLASSIFIED therefore survives only as a defensive fallback in the chip renderer:
|
|
131
|
+
// it is unreachable while the drop-filter is on, and exists so a future change that
|
|
132
|
+
// stops dropping unlabelled ads degrades to a visible "—" instead of `undefined`.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
const UNCLASSIFIED = 'Unclassified';
|
|
135
|
+
const PROMO_FILE = path.join(MA_DIR, 'promo-labels.json');
|
|
136
|
+
|
|
137
|
+
// OPTIONAL creative thumbnails, ad_id -> image path, in thumbs.json. By default the
|
|
138
|
+
// dashboard uses Meta's own preview_thumb from the fetch; this file overrides it, which
|
|
139
|
+
// matters on accounts where the creative pass is rate limited and previews come back null.
|
|
140
|
+
// No file, no override.
|
|
141
|
+
const THUMBS_FILE = path.join(MA_DIR, 'thumbs.json');
|
|
142
|
+
let thumbFiles = {};
|
|
143
|
+
if (fs.existsSync(THUMBS_FILE)) {
|
|
144
|
+
try { thumbFiles = JSON.parse(fs.readFileSync(THUMBS_FILE, 'utf-8')); }
|
|
145
|
+
catch (e) { console.warn(` ⚠ could not read ${path.basename(THUMBS_FILE)} — no thumbnails`); }
|
|
146
|
+
}
|
|
147
|
+
let promoLabels = {};
|
|
148
|
+
if (fs.existsSync(PROMO_FILE)) {
|
|
149
|
+
try { promoLabels = JSON.parse(fs.readFileSync(PROMO_FILE, 'utf-8')); }
|
|
150
|
+
catch (e) { console.warn(` ⚠ could not read ${path.basename(PROMO_FILE)} — promo filter disabled`); }
|
|
151
|
+
}
|
|
152
|
+
const HAS_PROMO = Object.keys(promoLabels).length > 0;
|
|
153
|
+
|
|
154
|
+
// last-3-days rollup from the daily series: avg daily spend, and CRR over those 3 days
|
|
155
|
+
// (summed spend / summed revenue, not an average of daily CRRs).
|
|
156
|
+
function last3(a) {
|
|
157
|
+
const d = (a.daily || []).slice(-3);
|
|
158
|
+
if (!d.length) return { spend3d: null, crr3d: null };
|
|
159
|
+
const spend = d.reduce((s, x) => s + (+x.spend || 0), 0);
|
|
160
|
+
const revenue = d.reduce((s, x) => s + (+x.revenue || 0), 0);
|
|
161
|
+
return {
|
|
162
|
+
spend3d: Math.round((spend / d.length) * 100) / 100, // avg per day
|
|
163
|
+
crr3d: revenue > 0 ? Math.round((spend / revenue) * 1000) / 10 : null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// With no promo-labels.json (the usual case) every spending ad is plotted. When labels
|
|
168
|
+
// ARE supplied, unlabelled ads are dropped rather than shown as "Unclassified" — keeping
|
|
169
|
+
// them would leave a bucket the reader cannot act on.
|
|
170
|
+
const _spent = cur.ads.filter(a => a.spend > 0);
|
|
171
|
+
const _labelled = HAS_PROMO ? _spent.filter(a => promoLabels[a.ad_id]) : _spent;
|
|
172
|
+
const _dropped = _spent.length - _labelled.length;
|
|
173
|
+
|
|
174
|
+
const ads = _labelled.map(a => {
|
|
175
|
+
const l3 = last3(a);
|
|
176
|
+
return {
|
|
177
|
+
id: a.ad_id, name: a.ad_name, adset: a.adset_name, campaign: a.campaign_name,
|
|
178
|
+
media: a.media_type, active: a.active,
|
|
179
|
+
spend: a.spend, revenue: a.revenue, purchases: a.purchases,
|
|
180
|
+
crr: a.crr, roas: a.roas, ctr: a.ctr, freq: a.frequency,
|
|
181
|
+
days: a.active_days,
|
|
182
|
+
spend3d: l3.spend3d, crr3d: l3.crr3d,
|
|
183
|
+
// a supplied thumbnail overrides Meta's preview, which is null on rate-limited accounts
|
|
184
|
+
thumb: thumbFiles[a.ad_id] || a.preview_thumb || null, image: a.preview_image || null,
|
|
185
|
+
promo: promoLabels[a.ad_id] || UNCLASSIFIED,
|
|
186
|
+
prevCrr: prevCrr[a.ad_id] != null ? prevCrr[a.ad_id] : null,
|
|
187
|
+
// trimmed daily series for the time-axis slider
|
|
188
|
+
daily: (a.daily || []).map(d => ({
|
|
189
|
+
date: d.date, spend: +d.spend || 0, revenue: +d.revenue || 0,
|
|
190
|
+
purchases: +d.purchases || 0, impr: +d.impr || 0, clicks: +d.clicks || 0,
|
|
191
|
+
})),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// unified, sorted, unique list of all days present in the window (YYYY-MM-DD)
|
|
196
|
+
const DAYS_AXIS = [...new Set(cur.ads.flatMap(a => (a.daily || []).map(d => d.date)))].sort();
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Data-seeded defaults (the user asked for these to come from real numbers).
|
|
200
|
+
//
|
|
201
|
+
// Target CRR = the account's blended CRR over the window (spend ÷ revenue). Starting the
|
|
202
|
+
// line at the account average splits the creatives into "better than my
|
|
203
|
+
// current average" vs "worse", which is the honest starting question.
|
|
204
|
+
// Spend gate = the 75th percentile of per-ad spend, floored at 3× AOV. An ad that has not
|
|
205
|
+
// yet spent a few average order values cannot be judged on revenue at all,
|
|
206
|
+
// and the median (~1 AOV here) would wave through far too many.
|
|
207
|
+
// Both are sliders — the user can move them freely; these are only the opening position.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
const spends = ads.map(a => a.spend).sort((x, y) => x - y);
|
|
210
|
+
function pct(arr, p) { return arr.length ? arr[Math.floor((arr.length - 1) * p)] : 0; }
|
|
211
|
+
|
|
212
|
+
const blendedCrr = (cur.totals && cur.totals.crr) || null;
|
|
213
|
+
const aov = (cur.totals && cur.totals.aov) || null;
|
|
214
|
+
|
|
215
|
+
const defTarget = Math.max(5, Math.round(blendedCrr || 40));
|
|
216
|
+
const p75Spend = pct(spends, 0.75);
|
|
217
|
+
const defSpendThresh = Math.max(20, Math.round((aov ? Math.max(p75Spend, aov * 3) : p75Spend) / 10) * 10);
|
|
218
|
+
|
|
219
|
+
// slider ceilings, generous enough to cover the data without becoming unusable
|
|
220
|
+
const targetMax = Math.max(100, Math.ceil((defTarget * 3) / 10) * 10);
|
|
221
|
+
const threshMax = Math.max(100, Math.ceil(Math.max(defSpendThresh * 4, pct(spends, 0.98)) / 50) * 50);
|
|
222
|
+
|
|
223
|
+
const html = `<!DOCTYPE html>
|
|
224
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
225
|
+
<title>Creative Matrix - ${esc(ACCOUNT_LABEL)}</title>
|
|
226
|
+
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
227
|
+
<style>
|
|
228
|
+
:root{
|
|
229
|
+
--bg:#F3F4F6;--card:#ffffff;--edge:#E5E7EB;--edge2:#eef0f3;
|
|
230
|
+
--ink:#111111;--muted:#6b7280;--muted2:#9ca3af;--accent:#ff5722;
|
|
231
|
+
--nd:#94a3b8;--wait:#f59e0b;--scale:#16a34a;--kill:#dc2626;
|
|
232
|
+
}
|
|
233
|
+
*{box-sizing:border-box}
|
|
234
|
+
body{margin:0;font-family:'Raleway',-apple-system,sans-serif;background:var(--bg);color:var(--ink);font-size:13px;-webkit-font-smoothing:antialiased}
|
|
235
|
+
|
|
236
|
+
/* header */
|
|
237
|
+
header{background:var(--card);border-bottom:1px solid var(--edge);padding:26px 0 22px}
|
|
238
|
+
.hwrap{max-width:1240px;margin:0 auto;padding:0 32px}
|
|
239
|
+
h1{margin:0 0 6px;font-size:26px;font-weight:600;letter-spacing:-.01em}
|
|
240
|
+
|
|
241
|
+
/* ---- Top bar ----
|
|
242
|
+
Same sticky pill navbar as the Ad Library report (prehled.html), so the two read as one
|
|
243
|
+
product rather than two loose files: site link left, client + agency logos right, split
|
|
244
|
+
by a hairline. */
|
|
245
|
+
.topbar{position:sticky;top:0;z-index:50;background:rgba(255,255,255,.82);
|
|
246
|
+
backdrop-filter:saturate(180%) blur(12px);-webkit-backdrop-filter:saturate(180%) blur(12px);
|
|
247
|
+
border-bottom:1px solid var(--edge2)}
|
|
248
|
+
.topbar-in{max-width:1460px;margin:0 auto;padding:11px 20px;display:flex;
|
|
249
|
+
align-items:center;justify-content:space-between;gap:16px}
|
|
250
|
+
.tb-home{font-size:12.5px;font-weight:600;color:var(--muted);text-decoration:none;
|
|
251
|
+
border:1px solid var(--edge);border-radius:999px;padding:6px 15px;transition:.14s}
|
|
252
|
+
.tb-home:hover{color:var(--accent);border-color:#ffccbc;background:#fff3f0}
|
|
253
|
+
.tb-logos{display:flex;align-items:center;gap:15px;flex:none}
|
|
254
|
+
.tb-logos img{width:auto;display:block}
|
|
255
|
+
.tb-client{height:32px}
|
|
256
|
+
.tb-agency{height:20px}
|
|
257
|
+
.tb-sep{width:1px;height:24px;background:var(--edge);flex:none}
|
|
258
|
+
@media(max-width:640px){.tb-logos{gap:10px}}
|
|
259
|
+
.sub{color:var(--muted);font-size:13px;line-height:1.5}
|
|
260
|
+
.controls{display:flex;gap:40px;flex-wrap:wrap;margin-top:22px;align-items:flex-start}
|
|
261
|
+
.ctl{min-width:220px}
|
|
262
|
+
.ctl label{display:flex;justify-content:space-between;align-items:baseline;color:var(--ink);font-size:12px;font-weight:600;margin-bottom:9px;text-transform:uppercase;letter-spacing:.05em}
|
|
263
|
+
.ctl .val{color:var(--accent);font-weight:700;font-size:15px;letter-spacing:0}
|
|
264
|
+
.valedit{width:60px;font-family:'Raleway',sans-serif;font-size:15px;font-weight:700;color:var(--accent);border:1px solid var(--accent);border-radius:5px;padding:1px 5px;background:#fff;text-align:right}
|
|
265
|
+
.valedit:focus{outline:none}
|
|
266
|
+
.valedit::-webkit-outer-spin-button,.valedit::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
|
|
267
|
+
.valedit[type=number]{-moz-appearance:textfield;appearance:textfield}
|
|
268
|
+
.hint{color:var(--muted2);font-size:11px;max-width:250px;margin-top:8px;line-height:1.4}
|
|
269
|
+
.roashint{color:var(--muted);font-weight:600}
|
|
270
|
+
/* clean slider */
|
|
271
|
+
input[type=range]{-webkit-appearance:none;appearance:none;width:230px;height:4px;border-radius:4px;background:var(--edge);outline:none}
|
|
272
|
+
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
|
|
273
|
+
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--accent);cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
|
|
274
|
+
|
|
275
|
+
.wrap{padding:26px 32px 48px;max-width:1240px;margin:0 auto}
|
|
276
|
+
|
|
277
|
+
/* time-axis slider above the plot */
|
|
278
|
+
.timeaxis{background:var(--card);border:1px solid var(--edge);border-radius:8px;padding:14px 18px;margin-bottom:14px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
|
|
279
|
+
.tahead{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;gap:16px;flex-wrap:wrap}
|
|
280
|
+
.talabel{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
|
|
281
|
+
.tacontrols{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
282
|
+
.tdash{color:var(--muted2)}
|
|
283
|
+
/* period picker */
|
|
284
|
+
.periodwrap{position:relative}
|
|
285
|
+
.periodbtn{display:inline-flex;align-items:center;gap:8px;font-family:'Raleway',sans-serif;font-size:13px;font-weight:600;color:var(--ink);border:1px solid var(--edge);border-radius:8px;padding:8px 14px;background:#fff;cursor:pointer}
|
|
286
|
+
.periodbtn:hover{border-color:var(--muted2)}
|
|
287
|
+
.periodbtn .cal{color:var(--muted);display:block}.periodbtn .caret{color:var(--muted2);font-size:10px}
|
|
288
|
+
/* above the sticky topbar (z 50) — the calendar opens downward but can overlap it on scroll */
|
|
289
|
+
.periodpop{position:absolute;top:calc(100% + 8px);right:0;z-index:60;display:flex;background:#fff;border:1px solid var(--edge);border-radius:10px;box-shadow:0 12px 32px rgba(16,24,40,.18);overflow:hidden}
|
|
290
|
+
.ppresets{width:170px;border-right:1px solid var(--edge);padding:8px 0;background:#fafafa}
|
|
291
|
+
.ppreset{display:block;width:100%;text-align:left;font-family:'Raleway',sans-serif;font-size:13px;color:var(--ink);padding:8px 18px;border:none;background:none;cursor:pointer}
|
|
292
|
+
.ppreset:hover{background:#fff}
|
|
293
|
+
.ppreset.on{color:var(--accent);font-weight:700}
|
|
294
|
+
.pcal{padding:16px 18px}
|
|
295
|
+
.pcalhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
|
296
|
+
.pnav{border:none;background:none;font-size:20px;color:var(--muted);cursor:pointer;padding:0 8px;line-height:1}
|
|
297
|
+
.pnav:hover{color:var(--ink)}
|
|
298
|
+
.pmonths{display:flex;gap:70px;font-size:15px;font-weight:600;color:var(--ink)}
|
|
299
|
+
.pgrids{display:flex;gap:24px}
|
|
300
|
+
.pgrid{display:grid;grid-template-columns:repeat(7,32px);gap:2px}
|
|
301
|
+
.pgh{font-size:10px;font-weight:600;color:var(--muted2);text-align:center;text-transform:uppercase;height:20px;line-height:20px}
|
|
302
|
+
.pday{height:32px;line-height:32px;text-align:center;font-size:12px;color:var(--ink);border-radius:6px;cursor:pointer;user-select:none}
|
|
303
|
+
.pday.mut{color:#cbd5e1;cursor:default}
|
|
304
|
+
.pday.off{color:#e2e8f0;cursor:not-allowed}
|
|
305
|
+
.pday:not(.mut):not(.off):hover{background:#fff2ec}
|
|
306
|
+
.pday.inrange{background:#ffe9e0}
|
|
307
|
+
.pday.edge{background:var(--accent);color:#fff;font-weight:700}
|
|
308
|
+
.pfoot{display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding-top:14px;border-top:1px solid var(--edge);gap:16px}
|
|
309
|
+
.pinputs{display:flex;align-items:center;gap:10px}
|
|
310
|
+
.pin{font-size:13px;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:6px 12px;min-width:120px;display:inline-block}
|
|
311
|
+
.pactions{display:flex;gap:8px}
|
|
312
|
+
.pbtn{font-family:'Raleway',sans-serif;font-size:13px;font-weight:600;padding:8px 16px;border-radius:6px;border:1px solid var(--edge);background:#fff;color:var(--ink);cursor:pointer}
|
|
313
|
+
.pbtn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
314
|
+
.pbtn.primary:hover{filter:brightness(1.05)}
|
|
315
|
+
.granbtns{display:inline-flex;border:1px solid var(--edge);border-radius:6px;overflow:hidden;margin-left:4px}
|
|
316
|
+
.granbtn{font-family:'Raleway',sans-serif;font-size:12px;font-weight:600;padding:6px 12px;border:none;background:#fff;color:var(--muted);cursor:pointer;border-left:1px solid var(--edge)}
|
|
317
|
+
.granbtn:first-child{border-left:none}
|
|
318
|
+
.granbtn.on{background:var(--accent);color:#fff}
|
|
319
|
+
.taday{font-size:13px;font-weight:500;color:var(--muted2)}
|
|
320
|
+
.timeaxis input[type=range]{width:100%;display:block}
|
|
321
|
+
/* ---- first-visit drag hint ----
|
|
322
|
+
The time slider is the least obvious control on the page and the most useful, so on a
|
|
323
|
+
fresh visit the thumb pulses and a hand points at it. Any interaction dismisses it for
|
|
324
|
+
good (localStorage), so it never nags a returning user. */
|
|
325
|
+
.tarow{position:relative}
|
|
326
|
+
/* anchored to the right, because the slider opens at "today" — the thumb sits at that end,
|
|
327
|
+
and the gesture to demonstrate is dragging back into the past (leftwards) */
|
|
328
|
+
.draghint{position:absolute;right:0;top:26px;display:flex;align-items:center;gap:8px;
|
|
329
|
+
flex-direction:row-reverse;pointer-events:none;z-index:4;
|
|
330
|
+
transition:opacity .35s,transform .35s;will-change:transform}
|
|
331
|
+
.draghint.hide{opacity:0;transform:translateY(-6px)}
|
|
332
|
+
.dhand{font-size:20px;line-height:1;display:block;animation:dhwave 1.9s ease-in-out infinite;transform-origin:60% 0}
|
|
333
|
+
.dhtext{background:var(--ink);color:#fff;font-size:11.5px;font-weight:600;padding:5px 10px;
|
|
334
|
+
border-radius:6px;white-space:nowrap;box-shadow:0 3px 10px rgba(16,24,40,.28)}
|
|
335
|
+
/* the hand slides left and back — the drag-into-the-past gesture the user should copy */
|
|
336
|
+
@keyframes dhwave{
|
|
337
|
+
0%,100%{transform:translateX(0)}
|
|
338
|
+
50%{transform:translateX(-26px)}
|
|
339
|
+
}
|
|
340
|
+
/* pulsing ring on the slider thumb, drawn as a sibling so the native thumb keeps working */
|
|
341
|
+
.thumbpulse{position:absolute;top:0;width:16px;height:16px;border-radius:50%;pointer-events:none;
|
|
342
|
+
z-index:3;transform:translate(-50%,-50%);box-shadow:0 0 0 0 rgba(255,87,34,.55);
|
|
343
|
+
animation:tpulse 1.9s ease-out infinite}
|
|
344
|
+
@keyframes tpulse{
|
|
345
|
+
0%{box-shadow:0 0 0 0 rgba(255,87,34,.5)}
|
|
346
|
+
70%{box-shadow:0 0 0 14px rgba(255,87,34,0)}
|
|
347
|
+
100%{box-shadow:0 0 0 0 rgba(255,87,34,0)}
|
|
348
|
+
}
|
|
349
|
+
@media (prefers-reduced-motion:reduce){
|
|
350
|
+
.dhand,.thumbpulse{animation:none}
|
|
351
|
+
}
|
|
352
|
+
.taends{display:flex;justify-content:space-between;align-items:center;color:var(--muted2);font-size:11px;margin-top:6px}
|
|
353
|
+
.plot{position:relative;background:var(--card);border:1px solid var(--edge);border-radius:8px;padding:14px 16px 8px;margin-bottom:8px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
|
|
354
|
+
.plottop{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:6px}
|
|
355
|
+
.aggstats{display:flex;gap:28px}
|
|
356
|
+
.aggbox{display:flex;flex-direction:column;gap:1px}
|
|
357
|
+
.agglab{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted2)}
|
|
358
|
+
.aggval{font-size:19px;font-weight:700;color:var(--ink);line-height:1.1}
|
|
359
|
+
.aggval .sm{font-size:12px;font-weight:600;color:var(--muted);margin-left:5px}
|
|
360
|
+
.info{color:var(--muted2);cursor:help;font-weight:400}
|
|
361
|
+
/* promo segmented control, sits next to the Active-only toggle */
|
|
362
|
+
.promowrap{display:flex;align-items:center;gap:9px;margin-left:auto}
|
|
363
|
+
.promolab2{font-size:11px;font-weight:600;color:var(--muted2);text-transform:uppercase;letter-spacing:.04em}
|
|
364
|
+
.promoseg{display:inline-flex;border:1px solid var(--edge);border-radius:6px;overflow:hidden}
|
|
365
|
+
.promobtn{font-family:'Raleway',sans-serif;font-size:12px;font-weight:600;padding:6px 11px;border:none;background:#fff;color:var(--muted);cursor:pointer;border-left:1px solid var(--edge);white-space:nowrap}
|
|
366
|
+
.promobtn:first-child{border-left:none}
|
|
367
|
+
.promobtn:hover{background:#fafafa}
|
|
368
|
+
.promobtn.on{background:var(--accent);color:#fff}
|
|
369
|
+
.promobtn.on:hover{background:var(--accent)}
|
|
370
|
+
.toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;flex:none;user-select:none}
|
|
371
|
+
.toggle .tglab{font-size:12px;font-weight:600;color:var(--ink);white-space:nowrap}
|
|
372
|
+
.toggle input{display:none}
|
|
373
|
+
.tgtrack{width:38px;height:22px;border-radius:22px;background:var(--edge);position:relative;transition:background .15s}
|
|
374
|
+
.tgknob{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:left .15s}
|
|
375
|
+
.toggle input:checked + .tgtrack{background:var(--accent)}
|
|
376
|
+
.toggle input:checked + .tgtrack .tgknob{left:18px}
|
|
377
|
+
#scatter{display:block;width:100%;height:560px;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}
|
|
378
|
+
#scatter text{font-family:'Raleway',sans-serif}
|
|
379
|
+
.zoneLabel{font-size:14px;font-weight:500;letter-spacing:.06em;pointer-events:none;font-family:'Raleway',sans-serif}
|
|
380
|
+
.gridline{stroke:var(--edge);stroke-width:1}
|
|
381
|
+
.threshline{stroke:var(--accent);stroke-width:1.5;stroke-dasharray:5 4;opacity:.7}
|
|
382
|
+
.axislabel{fill:var(--muted);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;font-family:'Raleway',sans-serif}
|
|
383
|
+
.ticklabel{fill:var(--muted2);font-size:10px;font-family:'Raleway',sans-serif}
|
|
384
|
+
.pt{cursor:pointer;stroke:#fff;stroke-width:1.5;transition:fill .4s ease,opacity .12s}
|
|
385
|
+
.pt:hover{stroke:var(--ink);stroke-width:2}
|
|
386
|
+
.pt.dim{opacity:.15}
|
|
387
|
+
.pt.sel{stroke:var(--ink);stroke-width:2.5}
|
|
388
|
+
.pt.nd{fill:var(--nd)}.pt.wait{fill:var(--wait)}.pt.scale{fill:var(--scale)}.pt.kill{fill:var(--kill)}
|
|
389
|
+
.zone{cursor:pointer}
|
|
390
|
+
.zone.nd{fill:rgba(148,163,184,.07)}.zone.wait{fill:rgba(245,158,11,.06)}
|
|
391
|
+
.zone.scale{fill:rgba(22,163,74,.06)}.zone.kill{fill:rgba(220,38,38,.05)}
|
|
392
|
+
.zone.sel{stroke:var(--accent);stroke-width:1.5}
|
|
393
|
+
/* stacked spend bar */
|
|
394
|
+
.barwrap{background:var(--card);border:1px solid var(--edge);border-radius:8px;padding:16px 18px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
|
|
395
|
+
.bartitle{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:12px}
|
|
396
|
+
.bartitle .bartotal{float:right;color:var(--ink);font-weight:700;text-transform:none;letter-spacing:0;font-size:13px}
|
|
397
|
+
.stacked{display:flex;height:38px;border-radius:6px;overflow:hidden;background:#f1f5f9}
|
|
398
|
+
.seg{position:relative;height:100%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:700;transition:filter .12s;min-width:0;white-space:nowrap;overflow:hidden}
|
|
399
|
+
.seg:hover{filter:brightness(1.06)}
|
|
400
|
+
.seg.dim{opacity:.35}
|
|
401
|
+
.seg.nd{background:var(--nd)}.seg.wait{background:var(--wait)}.seg.scale{background:var(--scale)}.seg.kill{background:var(--kill)}
|
|
402
|
+
.barlegend{display:flex;gap:20px;flex-wrap:wrap;margin-top:12px}
|
|
403
|
+
.blg{display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer}
|
|
404
|
+
.blg.dim{opacity:.4}
|
|
405
|
+
.blg .sw{width:10px;height:10px;border-radius:3px;flex:none}
|
|
406
|
+
.blg.nd .sw{background:var(--nd)}.blg.wait .sw{background:var(--wait)}.blg.scale .sw{background:var(--scale)}.blg.kill .sw{background:var(--kill)}
|
|
407
|
+
.blg .bn{font-weight:600;color:var(--ink)}
|
|
408
|
+
.blg .bm{color:var(--muted);font-weight:500}
|
|
409
|
+
.blg .bspend{color:var(--ink);font-weight:700}
|
|
410
|
+
.tip{position:absolute;pointer-events:none;background:var(--ink);border-radius:6px;padding:8px 11px;font-size:11px;color:#fff;display:none;z-index:5;max-width:250px;box-shadow:0 4px 12px rgba(0,0,0,.25);line-height:1.5}
|
|
411
|
+
.tip b{display:block;margin-bottom:3px;font-size:12px}
|
|
412
|
+
|
|
413
|
+
/* list */
|
|
414
|
+
.listhead{display:flex;align-items:center;gap:12px;margin:26px 0 12px;flex-wrap:wrap}
|
|
415
|
+
.dropfilters{display:flex;gap:8px;flex-wrap:wrap}
|
|
416
|
+
.dropfilter{font-family:'Raleway',sans-serif;font-size:12px;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:6px 10px;background:#fff;cursor:pointer;max-width:220px}
|
|
417
|
+
.dropfilter:focus{outline:none;border-color:var(--accent)}
|
|
418
|
+
.dropfilter.set{border-color:var(--accent);color:var(--accent);font-weight:600}
|
|
419
|
+
.listhead .pill{font-size:12px;font-weight:600;padding:5px 12px;border-radius:6px;display:flex;align-items:center;gap:6px;border:1px solid transparent}
|
|
420
|
+
.pill.nd{background:#f1f5f9;color:#475569;border-color:#e2e8f0}.pill.wait{background:#fffbeb;color:#b45309;border-color:#fde68a}
|
|
421
|
+
.pill.scale{background:#f0fdf4;color:#15803d;border-color:#bbf7d0}.pill.kill{background:#fef2f2;color:#b91c1c;border-color:#fecaca}
|
|
422
|
+
.pill.all{background:#fff;color:var(--muted);border-color:var(--edge)}
|
|
423
|
+
.clr{color:var(--accent);font-size:12px;font-weight:500;cursor:pointer}
|
|
424
|
+
.clr:hover{text-decoration:underline}
|
|
425
|
+
.tablecard{background:var(--card);border:1px solid var(--edge);border-radius:8px;overflow:hidden;box-shadow:0 1px 2px rgba(16,24,40,.04)}
|
|
426
|
+
table{width:100%;border-collapse:collapse}
|
|
427
|
+
th,td{text-align:left;padding:11px 14px;font-size:12px}
|
|
428
|
+
td{border-top:1px solid var(--edge2)}
|
|
429
|
+
thead th{background:var(--ink);color:#fff;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;user-select:none;white-space:nowrap}
|
|
430
|
+
thead th:hover{background:#2a2a2a}
|
|
431
|
+
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
432
|
+
tbody tr:hover{background:#fafafa}
|
|
433
|
+
.tname{display:flex;align-items:center;gap:10px}
|
|
434
|
+
.tthumb{width:38px;height:38px;border-radius:6px;object-fit:cover;background:#f1f5f9;flex:none;border:1px solid var(--edge);cursor:zoom-in;transition:transform .12s,box-shadow .12s}
|
|
435
|
+
.tthumb:hover{transform:scale(1.08);box-shadow:0 2px 8px rgba(16,24,40,.22);border-color:var(--muted2)}
|
|
436
|
+
/* creative lightbox */
|
|
437
|
+
.lb{position:fixed;inset:0;z-index:100;background:rgba(17,17,17,.82);display:none;align-items:center;justify-content:center;padding:32px}
|
|
438
|
+
.lb.on{display:flex}
|
|
439
|
+
.lbinner{position:relative;max-width:min(92vw,760px);max-height:92vh;display:flex;flex-direction:column;gap:10px}
|
|
440
|
+
.lbimg{max-width:100%;max-height:78vh;object-fit:contain;border-radius:10px;background:#fff;box-shadow:0 18px 50px rgba(0,0,0,.45)}
|
|
441
|
+
.lbbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;color:#fff}
|
|
442
|
+
.lbname{font-size:14px;font-weight:600;line-height:1.3}
|
|
443
|
+
.lbmeta{font-size:12px;color:#cfd4da;font-variant-numeric:tabular-nums}
|
|
444
|
+
.lbclose{position:absolute;top:-12px;right:-12px;width:32px;height:32px;border-radius:50%;border:none;background:#fff;color:var(--ink);font-size:19px;line-height:1;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
|
445
|
+
.lbclose:hover{background:#f1f5f9}
|
|
446
|
+
.lbnav{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,.92);color:var(--ink);font-size:22px;line-height:1;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.3)}
|
|
447
|
+
.lbnav:hover{background:#fff}
|
|
448
|
+
.lbprev{left:-56px}.lbnext{right:-56px}
|
|
449
|
+
@media (max-width:900px){.lbprev{left:4px}.lbnext{right:4px}}
|
|
450
|
+
.tnoimg{width:38px;height:38px;border-radius:6px;background:repeating-linear-gradient(45deg,#f5f7f9,#f5f7f9 4px,#eceff2 4px,#eceff2 8px);flex:none;border:1px solid var(--edge)}
|
|
451
|
+
/* name above, promo chip below — keeps the row to one column instead of two */
|
|
452
|
+
.nmwrap{display:flex;flex-direction:column;align-items:flex-start;gap:3px;min-width:0}
|
|
453
|
+
.tname .nm{font-weight:500;line-height:1.25}
|
|
454
|
+
.badge{font-size:9px;font-weight:600;padding:2px 6px;border-radius:4px;background:#f1f5f9;color:var(--muted);margin-left:7px;letter-spacing:.02em}
|
|
455
|
+
.badge.vid{background:#f3e8ff;color:#7c3aed}.badge.img{background:#e0f2fe;color:#0369a1}
|
|
456
|
+
.badge.unk{background:#f1f5f9;color:var(--muted2)}
|
|
457
|
+
tr.paused{color:var(--muted2)}
|
|
458
|
+
.chkcol{width:34px;text-align:center;padding-left:12px;padding-right:0}
|
|
459
|
+
.rowchk{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;vertical-align:middle}
|
|
460
|
+
tr.rowdim{opacity:.32;transition:opacity .12s}
|
|
461
|
+
tr.rowdim:hover{opacity:.7}
|
|
462
|
+
.st{font-size:11px;font-weight:600;display:inline-flex;align-items:center;gap:5px}
|
|
463
|
+
.st.on{color:var(--scale)}.st.off{color:var(--muted2)}
|
|
464
|
+
.st .d{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
|
|
465
|
+
.crr-good{color:var(--scale);font-weight:600}.crr-bad{color:var(--kill);font-weight:600}.crr-none{color:var(--muted2)}
|
|
466
|
+
.arrow{font-size:9px;margin-left:3px}.up{color:var(--kill)}.down{color:var(--scale)}
|
|
467
|
+
.bdot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:7px;vertical-align:middle}
|
|
468
|
+
.bdot.nd{background:var(--nd)}.bdot.wait{background:var(--wait)}.bdot.scale{background:var(--scale)}.bdot.kill{background:var(--kill)}
|
|
469
|
+
/* promo label chips — same palette as the Ad Library report */
|
|
470
|
+
.pmchip{font-size:9px;font-weight:700;padding:1px 6px;border-radius:4px;border:1px solid;white-space:nowrap;letter-spacing:.02em;line-height:1.5}
|
|
471
|
+
.pmchip.pm-main{background:#fdeaea;border-color:#f4c4c4;color:#a13a3a}
|
|
472
|
+
.pmchip.pm-both{background:#f1e8fa;border-color:#dcc8ef;color:#6b3f96}
|
|
473
|
+
.pmchip.pm-none{background:#eef1f4;border-color:#d5dbe1;color:#5a6672}
|
|
474
|
+
.pmchip.pm-unk{background:#fff;border-color:var(--edge);color:var(--muted2);font-weight:600}
|
|
475
|
+
</style></head>
|
|
476
|
+
<body>
|
|
477
|
+
<div class="topbar"><div class="topbar-in">
|
|
478
|
+
<a class="tb-home" href="https://adsrepo.com" target="_blank" rel="noopener">adsrepo.com →</a>
|
|
479
|
+
<div class="tb-logos">${LOGO_CLIENT ? `<img class="tb-client" src="${LOGO_CLIENT}" alt="${esc(ACCOUNT_LABEL)}">` : ''}${LOGO_CLIENT && LOGO_AGENCY ? '<span class="tb-sep"></span>' : ''}${LOGO_AGENCY ? `<img class="tb-agency" src="${LOGO_AGENCY}" alt="">` : ''}</div>
|
|
480
|
+
</div></div>
|
|
481
|
+
<header>
|
|
482
|
+
<div class="hwrap">
|
|
483
|
+
<h1>Creative Buckets - Meta Ads</h1>
|
|
484
|
+
<div class="sub">${esc(ACCOUNT_LABEL)} · ${cur.startDate} → ${cur.endDate} (${cur.days} days) · CRR = spend ÷ revenue (7d click) · billed in ${CURRENCY}</div>
|
|
485
|
+
<div class="controls">
|
|
486
|
+
<div class="ctl"><label>Target CRR <span class="val" id="v_target"></span></label>
|
|
487
|
+
<input type="range" id="s_target" min="1" max="${targetMax}" step="1">
|
|
488
|
+
<div class="hint">How much ad spend per 100 ${SYMBOL} of revenue you can afford. At or below = winner, 3× above = loser. <span class="roashint" id="v_roas"></span></div></div>
|
|
489
|
+
<div class="ctl"><label>Spend threshold <span class="val" id="v_thresh"></span></label>
|
|
490
|
+
<input type="range" id="s_thresh" min="10" max="${threshMax}" step="10">
|
|
491
|
+
<div class="hint">Minimum spend before an ad is judged. Under it → Not enough data.</div></div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</header>
|
|
495
|
+
|
|
496
|
+
<div class="wrap">
|
|
497
|
+
<div class="timeaxis">
|
|
498
|
+
<div class="tahead">
|
|
499
|
+
<span class="talabel">Time</span>
|
|
500
|
+
<div class="tacontrols">
|
|
501
|
+
<div class="periodwrap">
|
|
502
|
+
<button id="periodBtn" class="periodbtn"><svg class="cal" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg><span id="periodLabel">Last 30 days</span><span class="caret">▾</span></button>
|
|
503
|
+
<div id="periodPop" class="periodpop" style="display:none">
|
|
504
|
+
<div class="ppresets" id="presets"></div>
|
|
505
|
+
<div class="pcal">
|
|
506
|
+
<div class="pcalhead">
|
|
507
|
+
<button class="pnav" id="calPrev">‹</button>
|
|
508
|
+
<div class="pmonths"><span id="mo0"></span><span id="mo1"></span></div>
|
|
509
|
+
<button class="pnav" id="calNext">›</button>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="pgrids"><div id="grid0" class="pgrid"></div><div id="grid1" class="pgrid"></div></div>
|
|
512
|
+
<div class="pfoot">
|
|
513
|
+
<div class="pinputs"><span id="fFrom" class="pin"></span><span class="tdash">–</span><span id="fTo" class="pin"></span></div>
|
|
514
|
+
<div class="pactions"><button id="calCancel" class="pbtn">Cancel</button><button id="calUpdate" class="pbtn primary">Update</button></div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="granbtns">
|
|
520
|
+
<button data-g="day" class="granbtn on">Day</button>
|
|
521
|
+
<button data-g="week" class="granbtn">Week</button>
|
|
522
|
+
<button data-g="month" class="granbtn">Month</button>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
<div class="tarow">
|
|
527
|
+
<input type="range" id="s_day" min="0" step="1">
|
|
528
|
+
<div class="draghint" id="dragHint"><span class="dhand">👆</span><span class="dhtext">Drag me — watch the creatives move day by day</span></div>
|
|
529
|
+
</div>
|
|
530
|
+
<div class="taends"><span id="ta_start"></span><span class="taday" id="v_day"></span><span id="ta_end">today →</span></div>
|
|
531
|
+
</div>
|
|
532
|
+
<div class="plot">
|
|
533
|
+
<div class="plottop">
|
|
534
|
+
<div class="aggstats">
|
|
535
|
+
<div class="aggbox"><span class="agglab" id="aggScope">Spend</span><span class="aggval" id="aggSpend">-</span></div>
|
|
536
|
+
<div class="aggbox"><span class="agglab">Revenue</span><span class="aggval" id="aggRev">-</span></div>
|
|
537
|
+
<div class="aggbox"><span class="agglab">CRR <span class="info" title="Per-ad revenue double-counts purchases that several ads touched, so this reads roughly 8-12% better than the same period in Ads Manager at account level. Use it to rank creatives against each other, not as the account's true blended CRR.">ⓘ</span></span><span class="aggval" id="aggCrr">-</span></div>
|
|
538
|
+
</div>
|
|
539
|
+
<div class="promowrap"${HAS_PROMO ? '' : ' style="display:none"'}>
|
|
540
|
+
<span class="promolab2">Promo</span>
|
|
541
|
+
<div class="promoseg">
|
|
542
|
+
<button class="promobtn on" data-promo="">All ads</button>
|
|
543
|
+
<button class="promobtn" data-promo="Non-promo">Non-promo</button>
|
|
544
|
+
<button class="promobtn" data-promo="Promo - main message">Promo · main</button>
|
|
545
|
+
<button class="promobtn" data-promo="Promo - secondary message">Promo · secondary</button>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
<label class="toggle"><span class="tglab">Active only</span><input type="checkbox" id="activeOnly"><span class="tgtrack"><span class="tgknob"></span></span></label>
|
|
549
|
+
</div>
|
|
550
|
+
<svg id="scatter" viewBox="0 0 1000 560" preserveAspectRatio="none"></svg>
|
|
551
|
+
<div class="tip" id="tip"></div>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="barwrap">
|
|
554
|
+
<div class="bartitle">Spend distribution across buckets <span class="bartotal" id="barTotal"></span></div>
|
|
555
|
+
<div class="stacked" id="stacked"></div>
|
|
556
|
+
<div class="barlegend" id="barLegend"></div>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<div class="listhead">
|
|
560
|
+
<span class="pill all" id="filterPill">All creatives</span>
|
|
561
|
+
<div class="dropfilters">
|
|
562
|
+
<select id="fCampaign" class="dropfilter"><option value="">All campaigns</option></select>
|
|
563
|
+
<select id="fAdset" class="dropfilter"><option value="">All ad sets</option></select>
|
|
564
|
+
<select id="fAd" class="dropfilter"><option value="">All ads</option></select>
|
|
565
|
+
<select id="fStatus" class="dropfilter"><option value="">All statuses</option><option value="active">Active</option><option value="paused">Paused</option></select>
|
|
566
|
+
<select id="fPromo" class="dropfilter"${HAS_PROMO ? '' : ' style="display:none"'}><option value="">All promotion</option><option value="Non-promo">Non-promo</option><option value="Promo - main message">Promo · main</option><option value="Promo - secondary message">Promo · secondary</option></select>
|
|
567
|
+
</div>
|
|
568
|
+
<span class="clr" id="clearBtn" style="display:none">clear filter ✕</span>
|
|
569
|
+
</div>
|
|
570
|
+
<div class="tablecard">
|
|
571
|
+
<table>
|
|
572
|
+
<thead><tr>
|
|
573
|
+
<th class="chkcol"></th>
|
|
574
|
+
<th data-s="name">Creative</th>
|
|
575
|
+
<th data-s="bucket">Bucket</th>
|
|
576
|
+
<th data-s="active">Status</th>
|
|
577
|
+
<th data-s="spend" class="num">Spend</th>
|
|
578
|
+
<th data-s="revenue" class="num">Revenue</th>
|
|
579
|
+
<th data-s="purchases" class="num">Purch.</th>
|
|
580
|
+
<th data-s="crr" class="num">CRR</th>
|
|
581
|
+
<th data-s="roas" class="num">ROAS</th>
|
|
582
|
+
<th data-s="spend3d" class="num">Spend 3d/day</th>
|
|
583
|
+
<th data-s="crr3d" class="num">CRR 3d</th>
|
|
584
|
+
<th data-s="ctr" class="num">CTR</th>
|
|
585
|
+
<th data-s="days" class="num">Days</th>
|
|
586
|
+
</tr></thead>
|
|
587
|
+
<tbody id="tbody"></tbody>
|
|
588
|
+
</table>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<div class="lb" id="lb">
|
|
593
|
+
<div class="lbinner">
|
|
594
|
+
<button class="lbclose" id="lbClose" title="Close (Esc)">×</button>
|
|
595
|
+
<button class="lbnav lbprev" id="lbPrev" title="Previous (←)">‹</button>
|
|
596
|
+
<button class="lbnav lbnext" id="lbNext" title="Next (→)">›</button>
|
|
597
|
+
<img class="lbimg" id="lbImg" src="" alt="">
|
|
598
|
+
<div class="lbbar">
|
|
599
|
+
<div><div class="lbname" id="lbName"></div><div class="lbmeta" id="lbMeta"></div></div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
<script>
|
|
605
|
+
const ADS = ${jsonForScript(ads)};
|
|
606
|
+
const DAYS_AXIS = ${JSON.stringify(DAYS_AXIS)}; // every fetched day, sorted
|
|
607
|
+
const D = ${JSON.stringify({ defTarget, defSpendThresh })};
|
|
608
|
+
const CUR = ${JSON.stringify(SYMBOL)}; // currency symbol, from the ad account
|
|
609
|
+
|
|
610
|
+
// money formatter — symbol comes from the account, never hardcoded
|
|
611
|
+
function money(v){ return CUR==='Kč' ? Math.round(v).toLocaleString()+' Kč' : CUR+Math.round(v).toLocaleString(); }
|
|
612
|
+
|
|
613
|
+
// ---- date range + granularity (client-side, no re-fetch) ----
|
|
614
|
+
const DATA_MIN = DAYS_AXIS[0], DATA_MAX = DAYS_AXIS[DAYS_AXIS.length-1];
|
|
615
|
+
let rangeStart = DAYS_AXIS[Math.max(0, DAYS_AXIS.length-30)]; // default: last 30 fetched days
|
|
616
|
+
let rangeEnd = DATA_MAX;
|
|
617
|
+
let gran = 'day'; // 'day' | 'week' | 'month' — slider step granularity
|
|
618
|
+
let AXIS = []; // slider stops (subset of DAYS_AXIS in range, thinned by gran)
|
|
619
|
+
let dayIdx = 0; // index into AXIS (set to last stop after buildAxis)
|
|
620
|
+
|
|
621
|
+
// build the slider's stop list from the selected range + granularity.
|
|
622
|
+
// day = every day; week = every 7th day; month = one stop per calendar month.
|
|
623
|
+
// the range's last day is always the final stop so "today" is reachable.
|
|
624
|
+
function buildAxis(){
|
|
625
|
+
const inRange = DAYS_AXIS.filter(d => d>=rangeStart && d<=rangeEnd);
|
|
626
|
+
let stops;
|
|
627
|
+
if(gran==='day'){ stops = inRange.slice(); }
|
|
628
|
+
else if(gran==='week'){ stops = inRange.filter((d,i)=> i%7===0); }
|
|
629
|
+
else { // month: last day present for each YYYY-MM
|
|
630
|
+
const byMonth={}; for(const d of inRange) byMonth[d.slice(0,7)]=d; stops=Object.values(byMonth);
|
|
631
|
+
}
|
|
632
|
+
if(stops[stops.length-1]!==inRange[inRange.length-1]) stops.push(inRange[inRange.length-1]);
|
|
633
|
+
AXIS = stops.length ? stops : inRange.slice(-1);
|
|
634
|
+
dayIdx = AXIS.length-1;
|
|
635
|
+
}
|
|
636
|
+
buildAxis();
|
|
637
|
+
const LABELS = { nd:'Not enough data', wait:'Wait', scale:'Scale', kill:'Kill' };
|
|
638
|
+
const BUCKET_COLOR = { nd:'#94a3b8', wait:'#f59e0b', scale:'#16a34a', kill:'#dc2626' };
|
|
639
|
+
let filter = null; // active bucket filter (null = all)
|
|
640
|
+
let selIds = new Set(); // selected ad ids (checkboxes + dot clicks). empty = none selected.
|
|
641
|
+
let activeOnly = false; // "Active only" toggle — default OFF: show all ads (active + paused)
|
|
642
|
+
let fCamp='', fAds='', fAd=''; // cascading dropdown filters: campaign / ad set / ad
|
|
643
|
+
let fStatus=''; // status (active/paused)
|
|
644
|
+
// No media filter: Meta's creative pass is permanently rate limited on this account, so
|
|
645
|
+
// media_type is 'unknown' for every ad — a video/image dropdown would filter on a field
|
|
646
|
+
// that carries no signal. The promo filter took its place in the UI.
|
|
647
|
+
let fPromo=''; // promo label filter ('' = all); segment + dropdown share it
|
|
648
|
+
let sortKey = 'spend', sortDir = -1;
|
|
649
|
+
let visibleRows = []; // rows as currently sorted+filtered; the lightbox steps through these
|
|
650
|
+
|
|
651
|
+
const KILL_MULT = 3;
|
|
652
|
+
// final bucket for one creative.
|
|
653
|
+
// CRR is inverse ROAS, so LOWER is better — identical direction to CPA on the lead-gen
|
|
654
|
+
// variant, which is why these rules are unchanged from it.
|
|
655
|
+
function classify(a, t){
|
|
656
|
+
if(a.spend < t.thresh) return 'nd'; // spend gate — not judged yet
|
|
657
|
+
// no revenue at all: stays "not enough data" until it burns the kill gate, then kill.
|
|
658
|
+
// never "wait" — there's nothing to wait on without a single purchase.
|
|
659
|
+
if(!(a.revenue > 0)) return a.spend >= t.thresh * KILL_MULT ? 'kill' : 'nd';
|
|
660
|
+
if(a.crr >= t.target * KILL_MULT) return 'kill';
|
|
661
|
+
if(a.crr <= t.target) return 'scale';
|
|
662
|
+
return 'wait';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Cumulative state of every ad from rangeStart up to and including the given day (YYYY-MM-DD).
|
|
666
|
+
// Sums only days inside the selected date range, so picking "last 14 days" starts fresh at
|
|
667
|
+
// the range start rather than accumulating the whole window.
|
|
668
|
+
function snapshotFor(upto){
|
|
669
|
+
const out = [];
|
|
670
|
+
for(const a of ADS){
|
|
671
|
+
const win = a.daily.filter(d => d.date>=rangeStart && d.date<=upto);
|
|
672
|
+
let spend=0, revenue=0, purchases=0, impr=0, clicks=0, activeDays=0;
|
|
673
|
+
for(const d of win){ spend+=d.spend; revenue+=d.revenue; purchases+=d.purchases; impr+=d.impr; clicks+=d.clicks; if(d.impr>0) activeDays++; }
|
|
674
|
+
if(spend<=0) continue; // hasn't started spending yet by this day
|
|
675
|
+
// last-3-days rollup ending on the selected day
|
|
676
|
+
const l3 = win.slice(-3);
|
|
677
|
+
const s3 = l3.reduce((s,d)=>s+d.spend,0), r3 = l3.reduce((s,d)=>s+d.revenue,0);
|
|
678
|
+
// "active on the selected day" = it spent money that exact day (ran that day)
|
|
679
|
+
const today = a.daily.find(d => d.date===upto);
|
|
680
|
+
const activeToday = !!(today && today.spend>0);
|
|
681
|
+
out.push({
|
|
682
|
+
id:a.id, name:a.name, adset:a.adset, campaign:a.campaign, media:a.media, active:a.active,
|
|
683
|
+
activeToday,
|
|
684
|
+
thumb:a.thumb, image:a.image, prevCrr:a.prevCrr, promo:a.promo,
|
|
685
|
+
spend:Math.round(spend*100)/100, revenue:Math.round(revenue*100)/100, purchases,
|
|
686
|
+
crr: revenue>0 ? Math.round(spend/revenue*1000)/10 : null,
|
|
687
|
+
roas: revenue>0 ? Math.round(revenue/spend*100)/100 : null,
|
|
688
|
+
ctr: impr>0 ? Math.round(clicks/impr*10000)/100 : 0,
|
|
689
|
+
days: activeDays,
|
|
690
|
+
spend3d: l3.length ? Math.round(s3/l3.length*100)/100 : null,
|
|
691
|
+
crr3d: r3>0 ? Math.round(s3/r3*1000)/10 : null,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function fmtCrr(a){
|
|
698
|
+
if(a.crr == null) return '<span class="crr-none">-</span>';
|
|
699
|
+
let arrow='';
|
|
700
|
+
if(a.prevCrr != null && a.prevCrr>0){
|
|
701
|
+
const d=a.crr-a.prevCrr;
|
|
702
|
+
if(Math.abs(d)/a.prevCrr>=0.03) arrow = d>0 ? ' <span class="arrow up">▲</span>' : ' <span class="arrow down">▼</span>';
|
|
703
|
+
}
|
|
704
|
+
return a.crr.toFixed(1)+'%'+arrow;
|
|
705
|
+
}
|
|
706
|
+
function crrCls(a,t){ return a.crr==null?'crr-none':(a.crr<=t.target?'crr-good':(a.crr>=t.target*KILL_MULT?'crr-bad':'')); }
|
|
707
|
+
function crr3dCls(a,t){ return a.crr3d==null?'crr-none':(a.crr3d<=t.target?'crr-good':(a.crr3d>=t.target*KILL_MULT?'crr-bad':'')); }
|
|
708
|
+
function img(a){ return a.thumb||a.image; }
|
|
709
|
+
|
|
710
|
+
// promo label as a small coloured chip, matching the Ad Library report's colours
|
|
711
|
+
const HAS_PROMO = ${HAS_PROMO};
|
|
712
|
+
const PROMO_CLS = {
|
|
713
|
+
'Promo - main message':'pm-main',
|
|
714
|
+
'Promo - secondary message':'pm-both',
|
|
715
|
+
'Non-promo':'pm-none',
|
|
716
|
+
'${UNCLASSIFIED}':'pm-unk',
|
|
717
|
+
};
|
|
718
|
+
const PROMO_SHORT = {
|
|
719
|
+
'Promo - main message':'Promo · main',
|
|
720
|
+
'Promo - secondary message':'Promo · secondary',
|
|
721
|
+
'Non-promo':'Non-promo',
|
|
722
|
+
'${UNCLASSIFIED}':'—',
|
|
723
|
+
};
|
|
724
|
+
function promoChip(a){
|
|
725
|
+
const p=a.promo||'${UNCLASSIFIED}';
|
|
726
|
+
const t=p==='${UNCLASSIFIED}'?' title="Not in the Ad Library report, or a video (promo classification covered static creatives only)"':'';
|
|
727
|
+
return '<span class="pmchip '+(PROMO_CLS[p]||'pm-unk')+'"'+t+'>'+(PROMO_SHORT[p]||p)+'</span>';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ---- scatter geometry (viewBox 1000×560) ----
|
|
731
|
+
// main CRR plot on top, a separate "no revenue yet" strip at the bottom for ads without CRR.
|
|
732
|
+
const PLOT = { x0:60, x1:960, y0:30, y1:400, w:0, h:0 };
|
|
733
|
+
PLOT.w = PLOT.x1-PLOT.x0; PLOT.h = PLOT.y1-PLOT.y0;
|
|
734
|
+
const STRIP = { y0:440, y1:488, mid:464 }; // no-revenue band, below the plot
|
|
735
|
+
const SVGNS='http://www.w3.org/2000/svg';
|
|
736
|
+
|
|
737
|
+
// X = log10(spend) mapped across the plot. domain from data.
|
|
738
|
+
const SPENDS = ADS.map(a=>a.spend).filter(v=>v>0);
|
|
739
|
+
const SX_MIN = Math.log10(Math.max(5, Math.min(...SPENDS)) * 0.8);
|
|
740
|
+
const SX_MAX = Math.log10(Math.max(...SPENDS) * 1.15);
|
|
741
|
+
function xOf(spend){
|
|
742
|
+
const v = Math.log10(Math.max(1, spend));
|
|
743
|
+
const f = (v - SX_MIN) / (SX_MAX - SX_MIN || 1);
|
|
744
|
+
return PLOT.x0 + Math.max(0, Math.min(1, f)) * PLOT.w;
|
|
745
|
+
}
|
|
746
|
+
// Y = CRR %, 0 at bottom, capped at top. Ads without revenue pinned to the bottom strip.
|
|
747
|
+
// The cap is clamped to a sane multiple of the kill line: a single ad at CRR 600% would
|
|
748
|
+
// otherwise squash every real creative into the bottom pixel of the plot.
|
|
749
|
+
let CY_MAX = 10;
|
|
750
|
+
function computeCyMax(t){
|
|
751
|
+
const crrs = ADS.filter(a=>a.crr!=null).map(a=>a.crr).sort((x,y)=>x-y);
|
|
752
|
+
const p95 = crrs.length ? crrs[Math.floor((crrs.length-1)*0.95)] : t.target*3;
|
|
753
|
+
CY_MAX = Math.max(t.target*3.2, Math.min(p95*1.1, t.target*6));
|
|
754
|
+
}
|
|
755
|
+
function yOf(crr){
|
|
756
|
+
if(crr==null) return STRIP.mid; // no-revenue ads sit in the bottom strip
|
|
757
|
+
const f = crr / CY_MAX;
|
|
758
|
+
return PLOT.y1 - Math.max(0, Math.min(1, f)) * PLOT.h;
|
|
759
|
+
}
|
|
760
|
+
// dot radius scales with spend (area ∝ spend)
|
|
761
|
+
const MAX_SPEND = Math.max(1, ...ADS.map(a=>a.spend));
|
|
762
|
+
function rOf(spend){ return 4 + Math.sqrt(Math.max(0,spend)/MAX_SPEND) * 16; }
|
|
763
|
+
|
|
764
|
+
function el(ns, tag, attrs){ const e=document.createElementNS(ns,tag); for(const k in attrs) e.setAttribute(k,attrs[k]); return e; }
|
|
765
|
+
|
|
766
|
+
const dotEls={}; // ad id -> its <circle>, kept across renders so positions can animate
|
|
767
|
+
function drawScatter(t, snap){
|
|
768
|
+
const svg=document.getElementById('scatter');
|
|
769
|
+
// wipe only the static background layer; the dots live in a persistent <g> that we keep.
|
|
770
|
+
let bg=document.getElementById('bgLayer'), dots=document.getElementById('dotLayer');
|
|
771
|
+
if(!dots){
|
|
772
|
+
bg=el(SVGNS,'g',{id:'bgLayer'}); dots=el(SVGNS,'g',{id:'dotLayer'});
|
|
773
|
+
svg.innerHTML=''; svg.appendChild(bg); svg.appendChild(dots);
|
|
774
|
+
} else {
|
|
775
|
+
bg.innerHTML=''; // redraw static parts (zones/lines/axes depend on thresholds, not day)
|
|
776
|
+
}
|
|
777
|
+
const xT = xOf(t.thresh); // vertical spend-threshold line
|
|
778
|
+
const yT = yOf(t.target); // horizontal target-CRR line
|
|
779
|
+
const yK = yOf(t.target*KILL_MULT);// horizontal kill line
|
|
780
|
+
|
|
781
|
+
// spend share per bucket (for the "(NN%)" in each zone label)
|
|
782
|
+
const zoneSpend={nd:0,wait:0,scale:0,kill:0};
|
|
783
|
+
for(const a of snap){ zoneSpend[classify(a,t)] += a.spend; }
|
|
784
|
+
const zoneTotal=Object.values(zoneSpend).reduce((s,v)=>s+v,0)||1;
|
|
785
|
+
|
|
786
|
+
// zone rectangles (background), clickable → filter. left column = nd (full height).
|
|
787
|
+
const zones=[
|
|
788
|
+
{b:'nd', x:PLOT.x0, y:PLOT.y0, w:xT-PLOT.x0, h:PLOT.h}, // left = not enough data
|
|
789
|
+
{b:'scale',x:xT, y:yT, w:PLOT.x1-xT, h:PLOT.y1-yT}, // right & CRR good
|
|
790
|
+
{b:'wait', x:xT, y:yK, w:PLOT.x1-xT, h:yT-yK}, // right, between kill & target
|
|
791
|
+
{b:'kill', x:xT, y:PLOT.y0, w:PLOT.x1-xT, h:yK-PLOT.y0}, // right & CRR very high
|
|
792
|
+
];
|
|
793
|
+
for(const z of zones){
|
|
794
|
+
if(z.w<=0||z.h<=0) continue;
|
|
795
|
+
const r=el(SVGNS,'rect',{class:'zone '+z.b+(filter===z.b?' sel':''),x:z.x,y:z.y,width:z.w,height:z.h,rx:4,'data-b':z.b});
|
|
796
|
+
r.addEventListener('click',()=>setFilter(z.b));
|
|
797
|
+
bg.appendChild(r);
|
|
798
|
+
// zone label — inline font + colour so SVG text renders in Raleway on file://
|
|
799
|
+
const lb=el(SVGNS,'text',{class:'zoneLabel',x:z.x+10,y:z.y+20,fill:BUCKET_COLOR[z.b],
|
|
800
|
+
'font-family':"'Raleway',sans-serif",'font-size':'14','font-weight':'500','letter-spacing':'0.06em'});
|
|
801
|
+
const pct=Math.round(zoneSpend[z.b]/zoneTotal*100);
|
|
802
|
+
lb.textContent=LABELS[z.b].toUpperCase()+' ('+pct+'%)'; lb.style.pointerEvents='none';
|
|
803
|
+
bg.appendChild(lb);
|
|
804
|
+
}
|
|
805
|
+
// threshold lines (only inside the main plot)
|
|
806
|
+
bg.appendChild(el(SVGNS,'line',{class:'threshline',x1:xT,y1:PLOT.y0,x2:xT,y2:PLOT.y1}));
|
|
807
|
+
bg.appendChild(el(SVGNS,'line',{class:'threshline',x1:PLOT.x0,y1:yT,x2:PLOT.x1,y2:yT}));
|
|
808
|
+
bg.appendChild(el(SVGNS,'line',{class:'threshline',x1:xT,y1:yK,x2:PLOT.x1,y2:yK}));
|
|
809
|
+
|
|
810
|
+
// "no revenue yet" strip below the plot — ads with no purchase, positioned by spend only
|
|
811
|
+
bg.appendChild(el(SVGNS,'rect',{x:PLOT.x0,y:STRIP.y0,width:PLOT.w,height:STRIP.y1-STRIP.y0,rx:4,
|
|
812
|
+
fill:'#f8fafc',stroke:'#e5e7eb','stroke-width':1}));
|
|
813
|
+
bg.appendChild(txt(PLOT.x0+8, STRIP.y0-14, 'NO REVENUE YET (no CRR - placed by spend only)', 'axislabel', 'start'));
|
|
814
|
+
|
|
815
|
+
// axes labels
|
|
816
|
+
bg.appendChild(txt(PLOT.x0+PLOT.w/2, STRIP.y1+42, 'SPEND (log) →', 'axislabel', 'middle'));
|
|
817
|
+
const yl=txt(12, PLOT.y0+PLOT.h/2, 'CRR (%) ↑', 'axislabel', 'middle');
|
|
818
|
+
yl.setAttribute('transform',\`rotate(-90 12 \${PLOT.y0+PLOT.h/2})\`); bg.appendChild(yl);
|
|
819
|
+
|
|
820
|
+
// x ticks at nice spend values within domain — below the strip
|
|
821
|
+
[10,25,50,100,250,500,1000,2500,5000].forEach(v=>{
|
|
822
|
+
const lx=xOf(v); if(lx<PLOT.x0-1||lx>PLOT.x1+1) return;
|
|
823
|
+
bg.appendChild(el(SVGNS,'line',{class:'gridline',x1:lx,y1:STRIP.y1,x2:lx,y2:STRIP.y1+4}));
|
|
824
|
+
bg.appendChild(txt(lx, STRIP.y1+18, money(v), 'ticklabel', 'middle'));
|
|
825
|
+
});
|
|
826
|
+
// y ticks: target + kill get labels (ROAS shown alongside — same number, familiar framing)
|
|
827
|
+
bg.appendChild(txt(PLOT.x0-6, yT+3, t.target+'%', 'ticklabel', 'end'));
|
|
828
|
+
bg.appendChild(txt(PLOT.x0-6, yK+3, (t.target*KILL_MULT)+'%', 'ticklabel', 'end'));
|
|
829
|
+
|
|
830
|
+
// dots — from the day snapshot (cumulative state as of the selected day)
|
|
831
|
+
const counts={nd:0,wait:0,scale:0,kill:0};
|
|
832
|
+
const spends={nd:0,wait:0,scale:0,kill:0};
|
|
833
|
+
const present={};
|
|
834
|
+
for(const a of snap){
|
|
835
|
+
a._bucket=classify(a,t); counts[a._bucket]++; spends[a._bucket]+=a.spend; present[a.id]=a;
|
|
836
|
+
}
|
|
837
|
+
// Create each ad's <circle> ONCE, in whole-window spend order (stable stacking: big under small).
|
|
838
|
+
if(!dots.childElementCount){
|
|
839
|
+
const initOrder=ADS.slice().sort((x,y)=>y.spend-x.spend);
|
|
840
|
+
for(const ref of initOrder){
|
|
841
|
+
const c=el(SVGNS,'circle',{class:'pt nd',cx:0,cy:0,r:2,'data-id':ref.id});
|
|
842
|
+
c.__x=xOf(ref.spend); c.__y=yOf(ref.crr); c.__r=rOf(ref.spend); // current animated values
|
|
843
|
+
c.setAttribute('cx',c.__x); c.setAttribute('cy',c.__y); c.setAttribute('r',c.__r);
|
|
844
|
+
c.addEventListener('click',(e)=>{ e.stopPropagation(); if(c.__ad) toggleSel(c.__ad.id); });
|
|
845
|
+
c.addEventListener('mousemove',(e)=>{ if(c.__ad) showTip(e,c.__ad); });
|
|
846
|
+
c.addEventListener('mouseleave',hideTip);
|
|
847
|
+
dotEls[ref.id]=c; dots.appendChild(c);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Set each dot's TARGET position/size; class+visibility apply immediately, position tweened in JS.
|
|
851
|
+
for(const id in dotEls){
|
|
852
|
+
const c=dotEls[id], a=present[id];
|
|
853
|
+
if(!a){ c.style.display='none'; c.__ad=null; continue; }
|
|
854
|
+
let dim='';
|
|
855
|
+
if(selIds.size) dim = selIds.has(a.id) ? '' : ' dim'; // selection wins: highlight chosen, dim rest
|
|
856
|
+
else if(filter) dim = filter===a._bucket ? '' : ' dim';
|
|
857
|
+
const sel = selIds.has(a.id) ? ' sel':'';
|
|
858
|
+
c.__ad=a;
|
|
859
|
+
c.setAttribute('class','pt '+a._bucket+dim+sel);
|
|
860
|
+
c.setAttribute('data-b',a._bucket);
|
|
861
|
+
c.style.display='';
|
|
862
|
+
c.__tx=xOf(a.spend); c.__ty=yOf(a.crr); c.__tr=rOf(a.spend); // targets to animate toward
|
|
863
|
+
}
|
|
864
|
+
animateDots();
|
|
865
|
+
drawStackedBar(counts, spends);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// JS tween of cx/cy/r toward each dot's __tx/__ty/__tr targets (SVG attrs can't CSS-transition).
|
|
869
|
+
let animRAF=null, animStart=null;
|
|
870
|
+
const ANIM_MS=450;
|
|
871
|
+
function animateDots(){
|
|
872
|
+
if(animRAF) cancelAnimationFrame(animRAF);
|
|
873
|
+
for(const id in dotEls){ const c=dotEls[id]; c.__sx=c.__x; c.__sy=c.__y; c.__sr=c.__r; }
|
|
874
|
+
animStart=null;
|
|
875
|
+
const ease=p=>1-Math.pow(1-p,3); // easeOutCubic
|
|
876
|
+
function step(ts){
|
|
877
|
+
if(animStart==null) animStart=ts;
|
|
878
|
+
const p=Math.min(1,(ts-animStart)/ANIM_MS), e=ease(p);
|
|
879
|
+
for(const id in dotEls){
|
|
880
|
+
const c=dotEls[id];
|
|
881
|
+
if(c.__tx==null) continue;
|
|
882
|
+
c.__x=c.__sx+(c.__tx-c.__sx)*e;
|
|
883
|
+
c.__y=c.__sy+(c.__ty-c.__sy)*e;
|
|
884
|
+
c.__r=c.__sr+(c.__tr-c.__sr)*e;
|
|
885
|
+
c.setAttribute('cx',c.__x); c.setAttribute('cy',c.__y); c.setAttribute('r',c.__r);
|
|
886
|
+
}
|
|
887
|
+
if(p<1) animRAF=requestAnimationFrame(step); else animRAF=null;
|
|
888
|
+
}
|
|
889
|
+
animRAF=requestAnimationFrame(step);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// horizontal stacked bar: segment width = share of total spend
|
|
893
|
+
function drawStackedBar(counts, spends){
|
|
894
|
+
const order=['nd','wait','scale','kill'];
|
|
895
|
+
const totalSpend=order.reduce((s,b)=>s+spends[b],0) || 1;
|
|
896
|
+
set('barTotal', money(totalSpend)+' total');
|
|
897
|
+
const bar=document.getElementById('stacked'); bar.innerHTML='';
|
|
898
|
+
for(const b of order){
|
|
899
|
+
const pct=spends[b]/totalSpend*100;
|
|
900
|
+
if(pct<=0) continue;
|
|
901
|
+
const seg=document.createElement('div');
|
|
902
|
+
seg.className='seg '+b+(filter&&filter!==b?' dim':'');
|
|
903
|
+
seg.style.width=pct+'%';
|
|
904
|
+
seg.title=LABELS[b]+' - '+money(spends[b])+' ('+pct.toFixed(1)+'% of spend, '+counts[b]+' ads)';
|
|
905
|
+
if(pct>=8) seg.textContent=Math.round(pct)+'%';
|
|
906
|
+
seg.addEventListener('click',()=>setFilter(b));
|
|
907
|
+
bar.appendChild(seg);
|
|
908
|
+
}
|
|
909
|
+
// legend below with count + spend + %
|
|
910
|
+
const leg=document.getElementById('barLegend'); leg.innerHTML='';
|
|
911
|
+
for(const b of order){
|
|
912
|
+
const pct=Math.round(spends[b]/totalSpend*100);
|
|
913
|
+
const item=document.createElement('div');
|
|
914
|
+
item.className='blg '+b+(filter&&filter!==b?' dim':'');
|
|
915
|
+
item.innerHTML='<span class="sw"></span><span class="bn">'+LABELS[b]+'</span>'+
|
|
916
|
+
'<span class="bm"><b class="bspend">'+money(spends[b])+' ('+pct+'%)</b> · '+counts[b]+' ads</span>';
|
|
917
|
+
item.addEventListener('click',()=>setFilter(b));
|
|
918
|
+
leg.appendChild(item);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function txt(x,y,s,cls,anchor){ const e=el(SVGNS,'text',{x,y,class:cls,'text-anchor':anchor||'start'}); e.textContent=s; return e; }
|
|
922
|
+
|
|
923
|
+
const tip=document.getElementById('tip');
|
|
924
|
+
function showTip(e,a){
|
|
925
|
+
const rect=document.querySelector('.plot').getBoundingClientRect();
|
|
926
|
+
tip.style.display='block';
|
|
927
|
+
tip.style.left=(e.clientX-rect.left+12)+'px';
|
|
928
|
+
tip.style.top=(e.clientY-rect.top+12)+'px';
|
|
929
|
+
tip.innerHTML='<b>'+a.name+'</b>'+LABELS[a._bucket]+' · '+money(a.spend)+' spend · '+money(a.revenue)+' rev'+
|
|
930
|
+
'<br>CRR '+(a.crr==null?'-':a.crr.toFixed(1)+'%')+(a.roas!=null?' · ROAS '+a.roas.toFixed(2):'')+' · '+a.purchases+' purch.';
|
|
931
|
+
}
|
|
932
|
+
function hideTip(){ tip.style.display='none'; }
|
|
933
|
+
|
|
934
|
+
// While the time slider is being dragged we skip the table: rewriting 150+ rows of innerHTML
|
|
935
|
+
// (each with an img) on every input event costs tens of ms, and the resulting layout churn
|
|
936
|
+
// makes the browser drop the drag — the thumb would only respond to clicks, never to dragging.
|
|
937
|
+
// The scatter and the stat tiles still update live; the table catches up on release.
|
|
938
|
+
let skipList = false;
|
|
939
|
+
|
|
940
|
+
function render(){
|
|
941
|
+
const t={target:+val('s_target'),thresh:+val('s_thresh')};
|
|
942
|
+
set('v_target',t.target+'%'); set('v_thresh',money(t.thresh));
|
|
943
|
+
// ROAS echo — same threshold, the framing most people carry in their head
|
|
944
|
+
set('v_roas','= ROAS '+(100/t.target).toFixed(2)+' · kill line '+(t.target*KILL_MULT)+'% (ROAS '+(100/(t.target*KILL_MULT)).toFixed(2)+')');
|
|
945
|
+
computeCyMax(t);
|
|
946
|
+
let snap = snapshotFor(AXIS[dayIdx]);
|
|
947
|
+
if(activeOnly) snap = snap.filter(a=>a.activeToday); // "Active only" → only ads that spent on the selected day
|
|
948
|
+
if(fCamp) snap = snap.filter(a=>a.campaign===fCamp); // cascading campaign / ad set / ad filters
|
|
949
|
+
if(fAds) snap = snap.filter(a=>a.adset===fAds);
|
|
950
|
+
if(fAd) snap = snap.filter(a=>a.name===fAd);
|
|
951
|
+
if(fStatus) snap = snap.filter(a=>fStatus==='active'?a.active:!a.active); // status (Meta now)
|
|
952
|
+
if(fPromo) snap = snap.filter(a=>a.promo===fPromo); // promo label
|
|
953
|
+
updateDayLabel();
|
|
954
|
+
updateAggStats(snap, t);
|
|
955
|
+
drawScatter(t, snap);
|
|
956
|
+
if(skipList){
|
|
957
|
+
// keep visibleRows in step with the plot even when the table is not redrawn,
|
|
958
|
+
// so the lightbox never walks a stale list
|
|
959
|
+
for(const a of snap) a._bucket=classify(a,t);
|
|
960
|
+
visibleRows = filter ? snap.filter(a=>a._bucket===filter) : snap;
|
|
961
|
+
} else {
|
|
962
|
+
renderList(t, snap);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// aggregate Spend + Revenue + CRR of what's currently in focus.
|
|
967
|
+
// priority: checkbox selection > clicked bucket/zone > all shown ads.
|
|
968
|
+
// CRR = total spend / total revenue (weighted), not an average of per-ad CRRs.
|
|
969
|
+
function updateAggStats(snap, t){
|
|
970
|
+
let pool, scope;
|
|
971
|
+
if(selIds.size){ pool = snap.filter(a=>selIds.has(a.id)); scope = 'Spend ('+pool.length+' selected)'; }
|
|
972
|
+
else if(filter){ pool = snap.filter(a=>classify(a,t)===filter); scope = 'Spend ('+LABELS[filter]+')'; }
|
|
973
|
+
else { pool = snap; scope = 'Spend'; }
|
|
974
|
+
let spend=0, revenue=0;
|
|
975
|
+
for(const a of pool){ spend+=a.spend; revenue+=a.revenue; }
|
|
976
|
+
set('aggScope', scope);
|
|
977
|
+
set('aggSpend', money(spend));
|
|
978
|
+
set('aggRev', money(revenue));
|
|
979
|
+
const crrEl=document.getElementById('aggCrr');
|
|
980
|
+
crrEl.innerHTML = revenue>0
|
|
981
|
+
? (spend/revenue*100).toFixed(1)+'%<span class="sm">ROAS '+(revenue/spend).toFixed(2)+'</span>'
|
|
982
|
+
: '-';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// day-slider label: "Day D / N · YYYY-MM-DD" (today = last day of the whole dataset)
|
|
986
|
+
function updateDayLabel(){
|
|
987
|
+
const cur = AXIS[dayIdx];
|
|
988
|
+
const isToday = cur===DATA_MAX;
|
|
989
|
+
const unit = gran==='week' ? 'Week' : gran==='month' ? 'Month' : 'Day';
|
|
990
|
+
set('v_day', unit+' '+(dayIdx+1)+' / '+AXIS.length+' · '+cur+(isToday?' (today)':''));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function renderList(t, snap){
|
|
994
|
+
// table mirrors the graph: cumulative state as of the selected day/range.
|
|
995
|
+
for(const a of snap) a._bucket=classify(a,t);
|
|
996
|
+
let rows = snap.slice();
|
|
997
|
+
if(filter) rows = rows.filter(a=>a._bucket===filter); // bucket filter still hides other buckets
|
|
998
|
+
// selection does NOT hide rows — selected stay bright, the rest just dim (handled per-row below)
|
|
999
|
+
const dir=sortDir;
|
|
1000
|
+
rows.sort((x,y)=>{
|
|
1001
|
+
let vx=x[sortKey], vy=y[sortKey];
|
|
1002
|
+
if(sortKey==='name'||sortKey==='bucket'||sortKey==='promo'){
|
|
1003
|
+
const pick=(o)=> sortKey==='bucket'?o._bucket : (sortKey==='promo'?(o.promo||''):o.name);
|
|
1004
|
+
return dir*String(pick(x)||'').localeCompare(String(pick(y)||''));
|
|
1005
|
+
}
|
|
1006
|
+
// null CRR sorts last regardless of direction intent: "no data" is not "best".
|
|
1007
|
+
if(sortKey==='crr'){ vx=x.crr==null?Infinity:x.crr; vy=y.crr==null?Infinity:y.crr; }
|
|
1008
|
+
if(sortKey==='crr3d'){ vx=x.crr3d==null?Infinity:x.crr3d; vy=y.crr3d==null?Infinity:y.crr3d; }
|
|
1009
|
+
if(sortKey==='roas'){ vx=x.roas==null?-1:x.roas; vy=y.roas==null?-1:y.roas; }
|
|
1010
|
+
if(sortKey==='spend3d'){ vx=x.spend3d==null?-1:x.spend3d; vy=y.spend3d==null?-1:y.spend3d; }
|
|
1011
|
+
return dir*((vx||0)-(vy||0));
|
|
1012
|
+
});
|
|
1013
|
+
// keep the rows exactly as sorted/filtered, so the lightbox's ← → walk the same order
|
|
1014
|
+
visibleRows = rows;
|
|
1015
|
+
const tb=document.getElementById('tbody');
|
|
1016
|
+
tb.innerHTML = rows.map(a=>{
|
|
1017
|
+
const im=img(a);
|
|
1018
|
+
// No media badge: media_type is 'unknown' for every ad on this rate-limited account,
|
|
1019
|
+
// so a ▶/▢/? badge would be pure noise. The thumbnail shows the creative instead.
|
|
1020
|
+
const rowDim = selIds.size && !selIds.has(a.id) ? ' rowdim' : ''; // dim non-selected when any picked
|
|
1021
|
+
const checked = selIds.has(a.id) ? ' checked' : '';
|
|
1022
|
+
return \`<tr class="\${a.active?'':'paused'}\${rowDim}">
|
|
1023
|
+
<td class="chkcol"><input type="checkbox" class="rowchk" data-id="\${a.id}"\${checked}></td>
|
|
1024
|
+
<td><div class="tname">\${im?\`<img class="tthumb" src="\${im}" loading="lazy" alt="" data-id="\${a.id}" title="Click to enlarge">\`:'<div class="tnoimg" title="No creative image in the Ad Library scrape"></div>'}<div class="nmwrap"><span class="nm">\${a.name}</span>\${HAS_PROMO?promoChip(a):''}</div></div></td>
|
|
1025
|
+
<td><span class="bdot \${a._bucket}"></span>\${LABELS[a._bucket]}</td>
|
|
1026
|
+
<td>\${a.active?'<span class="st on"><span class="d"></span>Active</span>':'<span class="st off"><span class="d"></span>Paused</span>'}</td>
|
|
1027
|
+
<td class="num">\${money(a.spend)}</td>
|
|
1028
|
+
<td class="num">\${money(a.revenue)}</td>
|
|
1029
|
+
<td class="num">\${a.purchases}</td>
|
|
1030
|
+
<td class="num \${crrCls(a,t)}">\${fmtCrr(a)}</td>
|
|
1031
|
+
<td class="num">\${a.roas==null?'<span class="crr-none">-</span>':a.roas.toFixed(2)}</td>
|
|
1032
|
+
<td class="num">\${a.spend3d==null?'<span class="crr-none">-</span>':money(a.spend3d)}</td>
|
|
1033
|
+
<td class="num \${crr3dCls(a,t)}">\${a.crr3d==null?'<span class="crr-none">-</span>':a.crr3d.toFixed(1)+'%'}</td>
|
|
1034
|
+
<td class="num">\${a.ctr.toFixed(2)}%</td>
|
|
1035
|
+
<td class="num">\${a.days}</td>
|
|
1036
|
+
</tr>\`;
|
|
1037
|
+
}).join('') || '<tr><td colspan="13" style="color:var(--muted);text-align:center;padding:16px">no creatives in this bucket</td></tr>';
|
|
1038
|
+
|
|
1039
|
+
// wire the row checkboxes (toggle selection)
|
|
1040
|
+
document.querySelectorAll('.rowchk').forEach(chk=>chk.addEventListener('change',()=>toggleSel(chk.dataset.id)));
|
|
1041
|
+
|
|
1042
|
+
const pill=document.getElementById('filterPill');
|
|
1043
|
+
if(selIds.size){ pill.className='pill all'; pill.textContent=selIds.size+' selected'; document.getElementById('clearBtn').style.display=''; }
|
|
1044
|
+
else if(filter){ pill.className='pill '+filter; pill.textContent=LABELS[filter]+' ('+rows.length+')'; document.getElementById('clearBtn').style.display=''; }
|
|
1045
|
+
else { pill.className='pill all'; pill.textContent='All creatives ('+rows.length+')'; document.getElementById('clearBtn').style.display='none'; }
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// clicking a zone → filter that bucket (clears any selection)
|
|
1049
|
+
function setFilter(b){ selIds.clear(); filter = (filter===b)?null:b; render(); }
|
|
1050
|
+
// toggle an ad in/out of the selection (dot click or row checkbox)
|
|
1051
|
+
function toggleSel(id){ filter=null; if(selIds.has(id)) selIds.delete(id); else selIds.add(id); render(); }
|
|
1052
|
+
|
|
1053
|
+
function val(id){return document.getElementById(id).value}
|
|
1054
|
+
function set(id,v){document.getElementById(id).textContent=v}
|
|
1055
|
+
|
|
1056
|
+
document.getElementById('clearBtn').addEventListener('click',()=>{filter=null;selIds.clear();render();});
|
|
1057
|
+
document.querySelectorAll('th[data-s]').forEach(th=>th.addEventListener('click',()=>{
|
|
1058
|
+
const k=th.dataset.s; if(sortKey===k) sortDir*=-1; else {sortKey=k; sortDir=(k==='name'||k==='bucket'||k==='promo')?1:-1;} render();
|
|
1059
|
+
}));
|
|
1060
|
+
|
|
1061
|
+
// Two INDEPENDENT sliders. Unlike the lead-gen variant there is no "multiple" coupling:
|
|
1062
|
+
// a spend gate expressed as a multiple of a percentage target would be meaningless.
|
|
1063
|
+
const sTarget=document.getElementById('s_target'), sThresh=document.getElementById('s_thresh');
|
|
1064
|
+
sTarget.value=D.defTarget;
|
|
1065
|
+
sThresh.value=D.defSpendThresh;
|
|
1066
|
+
sTarget.addEventListener('input',render);
|
|
1067
|
+
sThresh.addEventListener('input',render);
|
|
1068
|
+
|
|
1069
|
+
// Make each value label click-to-edit: click the number, type a value,
|
|
1070
|
+
// Enter/blur applies it to the matching slider (clamped) and re-renders.
|
|
1071
|
+
function makeEditable(labelId, slider){
|
|
1072
|
+
const span=document.getElementById(labelId);
|
|
1073
|
+
span.style.cursor='text'; span.title='click to edit';
|
|
1074
|
+
span.addEventListener('click',()=>{
|
|
1075
|
+
if(span.querySelector('input')) return;
|
|
1076
|
+
const cur=+slider.value;
|
|
1077
|
+
const inp=document.createElement('input');
|
|
1078
|
+
inp.type='number'; inp.value=cur; inp.min=slider.min; inp.max=slider.max; inp.step=slider.step;
|
|
1079
|
+
inp.className='valedit';
|
|
1080
|
+
span.textContent=''; span.appendChild(inp); inp.focus(); inp.select();
|
|
1081
|
+
const commit=()=>{
|
|
1082
|
+
let v=parseFloat(inp.value);
|
|
1083
|
+
if(!isNaN(v)){
|
|
1084
|
+
v=Math.max(+slider.min, Math.min(+slider.max, v));
|
|
1085
|
+
slider.value=v;
|
|
1086
|
+
}
|
|
1087
|
+
render(); // repaint label either way
|
|
1088
|
+
};
|
|
1089
|
+
inp.addEventListener('keydown',e=>{ if(e.key==='Enter') inp.blur(); if(e.key==='Escape'){ inp.value=cur; inp.blur(); } });
|
|
1090
|
+
inp.addEventListener('blur',commit);
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
makeEditable('v_target', sTarget);
|
|
1094
|
+
makeEditable('v_thresh', sThresh);
|
|
1095
|
+
|
|
1096
|
+
// "Active only" toggle
|
|
1097
|
+
document.getElementById('activeOnly').addEventListener('change',e=>{ activeOnly=e.target.checked; render(); });
|
|
1098
|
+
|
|
1099
|
+
// ---- cascading Campaign / Ad Set / Ad dropdowns ----
|
|
1100
|
+
const selCamp=document.getElementById('fCampaign'), selAds=document.getElementById('fAdset'), selAd=document.getElementById('fAd');
|
|
1101
|
+
function fillSelect(sel, values, chosen, allLabel){
|
|
1102
|
+
sel.innerHTML='<option value="">'+allLabel+'</option>'+
|
|
1103
|
+
values.map(v=>'<option value="'+v.replace(/"/g,'"')+'"'+(v===chosen?' selected':'')+'>'+v+'</option>').join('');
|
|
1104
|
+
sel.classList.toggle('set', !!chosen);
|
|
1105
|
+
}
|
|
1106
|
+
function uniq(arr){ return [...new Set(arr)].sort((a,b)=>a.localeCompare(b)); }
|
|
1107
|
+
function populateDropdowns(){
|
|
1108
|
+
// campaigns: all; ad sets: only within chosen campaign; ads: only within chosen ad set (cascade)
|
|
1109
|
+
fillSelect(selCamp, uniq(ADS.map(a=>a.campaign)), fCamp, 'All campaigns');
|
|
1110
|
+
let adsetPool = ADS.filter(a=>!fCamp||a.campaign===fCamp);
|
|
1111
|
+
fillSelect(selAds, uniq(adsetPool.map(a=>a.adset)), fAds, 'All ad sets');
|
|
1112
|
+
let adPool = adsetPool.filter(a=>!fAds||a.adset===fAds);
|
|
1113
|
+
fillSelect(selAd, uniq(adPool.map(a=>a.name)), fAd, 'All ads');
|
|
1114
|
+
}
|
|
1115
|
+
selCamp.addEventListener('change',()=>{ fCamp=selCamp.value; fAds=''; fAd=''; populateDropdowns(); render(); });
|
|
1116
|
+
selAds.addEventListener('change',()=>{ fAds=selAds.value; fAd=''; populateDropdowns(); render(); });
|
|
1117
|
+
selAd.addEventListener('change',()=>{ fAd=selAd.value; populateDropdowns(); render(); });
|
|
1118
|
+
|
|
1119
|
+
// status + media dropdowns (independent, just AND with the rest)
|
|
1120
|
+
const selStatus=document.getElementById('fStatus');
|
|
1121
|
+
selStatus.addEventListener('change',()=>{ fStatus=selStatus.value; selStatus.classList.toggle('set',!!fStatus); render(); });
|
|
1122
|
+
|
|
1123
|
+
// ---- promo label filter ----
|
|
1124
|
+
// The segmented control next to "Active only" and the dropdown in the filter row are two
|
|
1125
|
+
// views of ONE state (fPromo), so changing either updates the other. Without that they
|
|
1126
|
+
// would drift apart and show contradictory selections.
|
|
1127
|
+
const selPromo=document.getElementById('fPromo');
|
|
1128
|
+
function syncPromoUI(){
|
|
1129
|
+
if(selPromo){ selPromo.value=fPromo; selPromo.classList.toggle('set',!!fPromo); }
|
|
1130
|
+
document.querySelectorAll('.promobtn').forEach(b=>b.classList.toggle('on', b.dataset.promo===fPromo));
|
|
1131
|
+
}
|
|
1132
|
+
function setPromo(v){ fPromo=v; syncPromoUI(); render(); }
|
|
1133
|
+
if(selPromo) selPromo.addEventListener('change',()=>setPromo(selPromo.value));
|
|
1134
|
+
document.querySelectorAll('.promobtn').forEach(b=>b.addEventListener('click',()=>setPromo(b.dataset.promo)));
|
|
1135
|
+
syncPromoUI();
|
|
1136
|
+
populateDropdowns();
|
|
1137
|
+
|
|
1138
|
+
// ---- time-axis: range + granularity + day slider ----
|
|
1139
|
+
const sDay=document.getElementById('s_day');
|
|
1140
|
+
|
|
1141
|
+
// date helpers (YYYY-MM-DD strings)
|
|
1142
|
+
const MONTHS=['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
1143
|
+
function ymd(d){ return d.toISOString().slice(0,10); }
|
|
1144
|
+
function parseD(s){ return new Date(s+'T00:00:00'); }
|
|
1145
|
+
function fmtNice(s){ const d=parseD(s); return d.getDate()+' '+MONTHS[d.getMonth()].slice(0,3)+' '+d.getFullYear(); }
|
|
1146
|
+
function addMonths(d,n){ return new Date(d.getFullYear(), d.getMonth()+n, 1); }
|
|
1147
|
+
|
|
1148
|
+
// point the slider at the current AXIS and refresh the visible labels
|
|
1149
|
+
function syncTimeUI(){
|
|
1150
|
+
sDay.max=AXIS.length-1; sDay.value=dayIdx;
|
|
1151
|
+
document.getElementById('ta_start').textContent=rangeStart;
|
|
1152
|
+
document.getElementById('ta_end').textContent=(rangeEnd===DATA_MAX?'today →':rangeEnd);
|
|
1153
|
+
const isPreset=PRESET_LABEL(); document.getElementById('periodLabel').textContent = isPreset || (fmtNice(rangeStart)+' – '+fmtNice(rangeEnd));
|
|
1154
|
+
}
|
|
1155
|
+
// if the current range matches a "last N days" preset, return its label, else null
|
|
1156
|
+
function PRESET_LABEL(){
|
|
1157
|
+
if(rangeEnd!==DATA_MAX) return null;
|
|
1158
|
+
for(const n of [7,14,28,30]){ if(rangeStart===DAYS_AXIS[Math.max(0,DAYS_AXIS.length-n)]) return 'Last '+n+' days'; }
|
|
1159
|
+
if(rangeStart===DATA_MIN) return 'Maximum';
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
function applyRange(from,to){
|
|
1163
|
+
rangeStart = from<DATA_MIN?DATA_MIN:from;
|
|
1164
|
+
rangeEnd = to>DATA_MAX?DATA_MAX:to;
|
|
1165
|
+
if(rangeStart>rangeEnd){ const s=rangeStart; rangeStart=rangeEnd; rangeEnd=s; }
|
|
1166
|
+
buildAxis(); syncTimeUI(); render();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// granularity buttons
|
|
1170
|
+
document.querySelectorAll('.granbtn').forEach(b=>b.addEventListener('click',()=>{
|
|
1171
|
+
document.querySelectorAll('.granbtn').forEach(x=>x.classList.remove('on'));
|
|
1172
|
+
b.classList.add('on'); gran=b.dataset.g; buildAxis(); syncTimeUI(); render();
|
|
1173
|
+
}));
|
|
1174
|
+
// Drag = plot only (see skipList above); the table is rebuilt once the thumb is released.
|
|
1175
|
+
// 'change' fires on release for mouse/touch and on every keypress for arrow keys, so both
|
|
1176
|
+
// input methods end with a full render.
|
|
1177
|
+
sDay.addEventListener('pointerdown',()=>{ skipList=true; });
|
|
1178
|
+
sDay.addEventListener('input',()=>{ dayIdx=+sDay.value; render(); });
|
|
1179
|
+
function endDayDrag(){
|
|
1180
|
+
if(!skipList) return;
|
|
1181
|
+
skipList=false;
|
|
1182
|
+
render();
|
|
1183
|
+
}
|
|
1184
|
+
sDay.addEventListener('change',endDayDrag);
|
|
1185
|
+
window.addEventListener('pointerup',endDayDrag);
|
|
1186
|
+
window.addEventListener('pointercancel',endDayDrag);
|
|
1187
|
+
|
|
1188
|
+
// ---- calendar popover ----
|
|
1189
|
+
const periodBtn=document.getElementById('periodBtn'), pop=document.getElementById('periodPop');
|
|
1190
|
+
let calMonth=addMonths(parseD(DATA_MAX),-1); // left month shown; right = calMonth+1
|
|
1191
|
+
let selFrom=rangeStart, selTo=rangeEnd, pickStage=0; // pickStage 0 = pick start next
|
|
1192
|
+
|
|
1193
|
+
function buildPresets(){
|
|
1194
|
+
const box=document.getElementById('presets'); box.innerHTML='';
|
|
1195
|
+
const items=[['Last 7 days',7],['Last 14 days',14],['Last 28 days',28],['Last 30 days',30],['Maximum',9999]];
|
|
1196
|
+
for(const [lab,n] of items){
|
|
1197
|
+
const b=document.createElement('button'); b.className='ppreset'; b.textContent=lab;
|
|
1198
|
+
b.addEventListener('click',()=>{
|
|
1199
|
+
selFrom = n>=9999 ? DATA_MIN : DAYS_AXIS[Math.max(0,DAYS_AXIS.length-n)];
|
|
1200
|
+
selTo=DATA_MAX; pickStage=0; calMonth=addMonths(parseD(selTo),-1); drawCal();
|
|
1201
|
+
});
|
|
1202
|
+
box.appendChild(b);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function monthGrid(base, gridEl, hdrEl){
|
|
1206
|
+
hdrEl.textContent=MONTHS[base.getMonth()]+' '+base.getFullYear();
|
|
1207
|
+
gridEl.innerHTML='';
|
|
1208
|
+
['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].forEach(d=>{ const h=document.createElement('div'); h.className='pgh'; h.textContent=d; gridEl.appendChild(h); });
|
|
1209
|
+
const first=new Date(base.getFullYear(),base.getMonth(),1);
|
|
1210
|
+
let lead=(first.getDay()+6)%7; // Mon-first offset
|
|
1211
|
+
for(let i=0;i<lead;i++){ const e=document.createElement('div'); e.className='pday mut'; gridEl.appendChild(e); }
|
|
1212
|
+
const dim=new Date(base.getFullYear(),base.getMonth()+1,0).getDate();
|
|
1213
|
+
for(let day=1;day<=dim;day++){
|
|
1214
|
+
const ds=ymd(new Date(base.getFullYear(),base.getMonth(),day));
|
|
1215
|
+
const cell=document.createElement('div'); cell.textContent=day;
|
|
1216
|
+
const disabled = ds<DATA_MIN || ds>DATA_MAX;
|
|
1217
|
+
let cls='pday'+(disabled?' off':'');
|
|
1218
|
+
if(!disabled){
|
|
1219
|
+
if(ds===selFrom||ds===selTo) cls+=' edge';
|
|
1220
|
+
else if(ds>selFrom&&ds<selTo) cls+=' inrange';
|
|
1221
|
+
cell.addEventListener('click',()=>{
|
|
1222
|
+
if(pickStage===0){ selFrom=ds; selTo=ds; pickStage=1; }
|
|
1223
|
+
else { if(ds<selFrom){ selTo=selFrom; selFrom=ds; } else selTo=ds; pickStage=0; }
|
|
1224
|
+
drawCal();
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
cell.className=cls; gridEl.appendChild(cell);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function drawCal(){
|
|
1231
|
+
monthGrid(calMonth, document.getElementById('grid0'), document.getElementById('mo0'));
|
|
1232
|
+
monthGrid(addMonths(calMonth,1), document.getElementById('grid1'), document.getElementById('mo1'));
|
|
1233
|
+
document.getElementById('fFrom').textContent=fmtNice(selFrom);
|
|
1234
|
+
document.getElementById('fTo').textContent=fmtNice(selTo);
|
|
1235
|
+
const pl=(function(){ if(selTo!==DATA_MAX) return null; for(const n of [7,14,28,30]){ if(selFrom===DAYS_AXIS[Math.max(0,DAYS_AXIS.length-n)]) return 'Last '+n+' days'; } if(selFrom===DATA_MIN) return 'Maximum'; return null; })();
|
|
1236
|
+
document.querySelectorAll('.ppreset').forEach(b=>b.classList.toggle('on', b.textContent===pl));
|
|
1237
|
+
}
|
|
1238
|
+
function openPop(){ selFrom=rangeStart; selTo=rangeEnd; pickStage=0; calMonth=addMonths(parseD(rangeEnd),-1); drawCal(); pop.style.display='flex'; }
|
|
1239
|
+
function closePop(){ pop.style.display='none'; }
|
|
1240
|
+
periodBtn.addEventListener('click',(e)=>{ e.stopPropagation(); pop.style.display==='none'?openPop():closePop(); });
|
|
1241
|
+
pop.addEventListener('click',e=>e.stopPropagation());
|
|
1242
|
+
document.addEventListener('click',closePop);
|
|
1243
|
+
document.getElementById('calPrev').addEventListener('click',()=>{ calMonth=addMonths(calMonth,-1); drawCal(); });
|
|
1244
|
+
document.getElementById('calNext').addEventListener('click',()=>{ calMonth=addMonths(calMonth,1); drawCal(); });
|
|
1245
|
+
document.getElementById('calCancel').addEventListener('click',closePop);
|
|
1246
|
+
document.getElementById('calUpdate').addEventListener('click',()=>{ applyRange(selFrom,selTo); closePop(); });
|
|
1247
|
+
|
|
1248
|
+
// ---- creative lightbox ----
|
|
1249
|
+
// Clicking a row thumbnail opens the bigger copy (thumbs/<id>@full.jpg, written alongside
|
|
1250
|
+
// the small one by extract-thumbs.cjs). Arrows step through whatever the table currently
|
|
1251
|
+
// shows, so the lightbox honours the active filters instead of walking all 154 ads.
|
|
1252
|
+
const lb=document.getElementById('lb'), lbImg=document.getElementById('lbImg');
|
|
1253
|
+
const lbName=document.getElementById('lbName'), lbMeta=document.getElementById('lbMeta');
|
|
1254
|
+
let lbList=[], lbIdx=-1;
|
|
1255
|
+
|
|
1256
|
+
function fullSrc(a){ return a.thumb ? a.thumb.replace(/\\.jpg$/, '@full.jpg') : ''; }
|
|
1257
|
+
|
|
1258
|
+
function lbShow(i){
|
|
1259
|
+
if(!lbList.length) return;
|
|
1260
|
+
lbIdx=(i+lbList.length)%lbList.length; // wrap around at both ends
|
|
1261
|
+
const a=lbList[lbIdx];
|
|
1262
|
+
// fall back to the small image if the @full copy is missing, rather than a broken icon
|
|
1263
|
+
lbImg.onerror=()=>{ lbImg.onerror=null; lbImg.src=a.thumb||''; };
|
|
1264
|
+
lbImg.src=fullSrc(a);
|
|
1265
|
+
lbImg.alt=a.name||'';
|
|
1266
|
+
lbName.textContent=a.name||'';
|
|
1267
|
+
lbMeta.textContent=[
|
|
1268
|
+
LABELS[a._bucket]||'',
|
|
1269
|
+
a.promo||'',
|
|
1270
|
+
money(a.spend)+' spend',
|
|
1271
|
+
money(a.revenue)+' rev',
|
|
1272
|
+
a.crr==null?'CRR -':'CRR '+a.crr.toFixed(1)+'%',
|
|
1273
|
+
a.roas==null?'':'ROAS '+a.roas.toFixed(2),
|
|
1274
|
+
(lbIdx+1)+' / '+lbList.length,
|
|
1275
|
+
].filter(Boolean).join(' · ');
|
|
1276
|
+
lb.classList.add('on');
|
|
1277
|
+
}
|
|
1278
|
+
function lbClose(){ lb.classList.remove('on'); lbImg.src=''; lbIdx=-1; }
|
|
1279
|
+
|
|
1280
|
+
// delegated: rows are re-rendered constantly, so bind once on the table body
|
|
1281
|
+
document.getElementById('tbody').addEventListener('click',e=>{
|
|
1282
|
+
const img=e.target.closest('.tthumb');
|
|
1283
|
+
if(!img) return;
|
|
1284
|
+
e.stopPropagation(); // don't toggle row selection
|
|
1285
|
+
const id=img.dataset.id;
|
|
1286
|
+
lbList=visibleRows.filter(a=>a.thumb); // only ads that have an image
|
|
1287
|
+
const i=lbList.findIndex(a=>a.id===id);
|
|
1288
|
+
if(i>=0) lbShow(i);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
document.getElementById('lbClose').addEventListener('click',lbClose);
|
|
1292
|
+
document.getElementById('lbPrev').addEventListener('click',e=>{ e.stopPropagation(); lbShow(lbIdx-1); });
|
|
1293
|
+
document.getElementById('lbNext').addEventListener('click',e=>{ e.stopPropagation(); lbShow(lbIdx+1); });
|
|
1294
|
+
lb.addEventListener('click',e=>{ if(e.target===lb) lbClose(); }); // click the backdrop to dismiss
|
|
1295
|
+
document.addEventListener('keydown',e=>{
|
|
1296
|
+
if(!lb.classList.contains('on')) return;
|
|
1297
|
+
if(e.key==='Escape') lbClose();
|
|
1298
|
+
else if(e.key==='ArrowLeft') lbShow(lbIdx-1);
|
|
1299
|
+
else if(e.key==='ArrowRight') lbShow(lbIdx+1);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// ---- first-visit hint on the time slider ----
|
|
1303
|
+
// The slider is the page's least discoverable control, so point at it once. The pulse ring
|
|
1304
|
+
// is positioned over the native thumb (which cannot be styled with a sibling animation
|
|
1305
|
+
// directly), and both it and the hand disappear on the first real interaction.
|
|
1306
|
+
const HINT_KEY='creativeBucketsDragHintSeen';
|
|
1307
|
+
const dragHint=document.getElementById('dragHint');
|
|
1308
|
+
let thumbPulse=null;
|
|
1309
|
+
|
|
1310
|
+
function thumbLeft(){
|
|
1311
|
+
// mirror the native thumb position: 16px wide, so its centre travels (width-16) px
|
|
1312
|
+
const r=sDay.getBoundingClientRect(), max=+sDay.max||1, v=+sDay.value;
|
|
1313
|
+
return 8 + (v/max)*(r.width-16);
|
|
1314
|
+
}
|
|
1315
|
+
function placePulse(){
|
|
1316
|
+
if(!thumbPulse) return;
|
|
1317
|
+
thumbPulse.style.left=thumbLeft()+'px';
|
|
1318
|
+
thumbPulse.style.top=(sDay.offsetTop + sDay.offsetHeight/2)+'px';
|
|
1319
|
+
}
|
|
1320
|
+
// localStorage can throw on file:// or in private windows — treat that as "not seen yet"
|
|
1321
|
+
// rather than letting it break the page.
|
|
1322
|
+
function hintSeen(){ try{ return !!localStorage.getItem(HINT_KEY); }catch(e){ return false; } }
|
|
1323
|
+
|
|
1324
|
+
function showHint(){
|
|
1325
|
+
if(!dragHint) return;
|
|
1326
|
+
if(hintSeen()) { dragHint.remove(); return; }
|
|
1327
|
+
// Decorative only — never let a missing node take the dashboard down with it.
|
|
1328
|
+
try{
|
|
1329
|
+
thumbPulse=document.createElement('div');
|
|
1330
|
+
thumbPulse.className='thumbpulse';
|
|
1331
|
+
sDay.parentNode.appendChild(thumbPulse);
|
|
1332
|
+
placePulse();
|
|
1333
|
+
window.addEventListener('resize', placePulse);
|
|
1334
|
+
}catch(e){ thumbPulse=null; }
|
|
1335
|
+
}
|
|
1336
|
+
function dismissHint(){
|
|
1337
|
+
if(!dragHint || dragHint.classList.contains('hide')) return;
|
|
1338
|
+
dragHint.classList.add('hide');
|
|
1339
|
+
if(thumbPulse) thumbPulse.remove();
|
|
1340
|
+
try{ localStorage.setItem(HINT_KEY,'1'); }catch(e){}
|
|
1341
|
+
setTimeout(()=>dragHint.remove(), 400);
|
|
1342
|
+
}
|
|
1343
|
+
// any real use of the slider (or of the period picker, which does the same job) ends it
|
|
1344
|
+
sDay.addEventListener('pointerdown',dismissHint);
|
|
1345
|
+
sDay.addEventListener('input',dismissHint);
|
|
1346
|
+
sDay.addEventListener('keydown',dismissHint);
|
|
1347
|
+
periodBtn.addEventListener('click',dismissHint);
|
|
1348
|
+
|
|
1349
|
+
buildPresets();
|
|
1350
|
+
syncTimeUI();
|
|
1351
|
+
render();
|
|
1352
|
+
|
|
1353
|
+
// after the first render: the slider now has its real width and max, so the pulse ring
|
|
1354
|
+
// lands on the actual thumb rather than at a stale position
|
|
1355
|
+
showHint();
|
|
1356
|
+
</script>
|
|
1357
|
+
</body></html>`;
|
|
1358
|
+
|
|
1359
|
+
const out = path.join(MA_DIR, 'buckets-matrix.html');
|
|
1360
|
+
fs.writeFileSync(out, html);
|
|
1361
|
+
console.log(`✓ Matrix written: ${out}`);
|
|
1362
|
+
console.log(` account: ${ACCOUNT_LABEL} (${cur.account}) · ${CURRENCY}`);
|
|
1363
|
+
console.log(` window: ${cur.startDate} → ${cur.endDate} (${cur.days} days) · ${ads.length} ads with spend`);
|
|
1364
|
+
if (_dropped) console.log(` dropped: ${_dropped} ads with no promo label ` +
|
|
1365
|
+
`— every remaining ad is classified`);
|
|
1366
|
+
console.log(` defaults: target CRR ${defTarget}% (ROAS ${(100 / defTarget).toFixed(2)}, = account blended) · spend gate ${SYMBOL}${defSpendThresh}`);
|
|
1367
|
+
try { execSync(`open "${out}"`); } catch (e) { /* non-mac / headless: skip auto-open */ }
|