/* Creative status explainer — shared by meta-ads-lifetime-dashboard-team.html and * mona-dashboard.html ("Creatives (last 60 days)" tables). * * Single source of truth for the SCALE / HOLD / FIX / KILL / NO DATA rule and for * the hover popover that explains that verdict from the row's own 60-day numbers. * Rule-based (cost per Calendly booking thresholds), not a model prediction. * * Usage: * CreativeStatusExplainer.statusFor(creative) -> { status, cls } * CreativeStatusExplainer.explain(creative, allCreatives) -> HTML string * CreativeStatusExplainer.attach({ root, selector, resolve }) * root: element to delegate hover/focus events on (default document) * selector: badge selector, e.g. '.status-badge' * resolve: badge -> { creative, all } (all = the creatives array for averages) * Generic use (any hover popover, e.g. a chart mirror): * CreativeStatusExplainer.attach({ root, selector, render, width }) * render: trigger element -> HTML string (used instead of resolve/explain) * width: popover width in px (default 360) * attach() returns { show(el), hide() } so async renders can refresh the popover. */ (function (global) { 'use strict'; var CPR_SCALE_MAX = 55, CPR_HOLD_MAX = 80, CPR_FIX_MAX = 120; // $ per Calendly booking (60-day) var CTR_STRONG = 1.5, CTR_OK = 1.0; // link CTR % var VOLUME_BAND = 0.15; // ±15% of avg impressions = "around" var FATIGUE_DAYS = 120; var POPOVER_ID = 'status-popover'; var CSS = '' + '#' + POPOVER_ID + '{position:fixed;z-index:9999;width:360px;max-width:calc(100vw - 24px);' + 'background:var(--card,#161b22);color:var(--text,#e6edf3);border:1px solid var(--border,#30363d);border-radius:12px;' + 'padding:14px 16px;box-shadow:0 12px 32px rgba(0,0,0,0.45);font-size:0.82rem;line-height:1.45;' + 'pointer-events:none;display:none;text-align:left;white-space:normal;}' + '#' + POPOVER_ID + '.show{display:block;}' + '#' + POPOVER_ID + ' h4{margin:0 0 6px;font-size:0.9rem;}' + '#' + POPOVER_ID + ' .sp-why{margin:0 0 8px;}' + '#' + POPOVER_ID + ' ul{margin:0 0 8px;padding-left:18px;}' + '#' + POPOVER_ID + ' li{margin:2px 0;}' + '#' + POPOVER_ID + ' .sp-foot{color:var(--muted,var(--text-muted,#8b949e));font-size:0.72rem;border-top:1px solid var(--border,#30363d);padding-top:6px;margin-top:6px;}' + '#' + POPOVER_ID + ' .good{color:#34d399;font-weight:600;}' + '#' + POPOVER_ID + ' .warn{color:#fbbf24;font-weight:600;}' + '#' + POPOVER_ID + ' .bad{color:#f87171;font-weight:600;}' + '.status-explain-badge{cursor:help;outline:none;}' + '.status-explain-badge:focus-visible{box-shadow:0 0 0 2px #818cf8;}'; var money = function (v) { return '$' + Math.round(v).toLocaleString(); }; var pctDiff = function (a, b) { return b > 0 ? Math.round((a - b) / b * 100) : 0; }; var esc = function (str) { return String(str).replace(/[&<>"]/g, function (ch) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[ch]; }); }; function statusFor(c) { if (c.results === 0) return { status: 'NO DATA', cls: 'kill' }; if (c.cpr <= CPR_SCALE_MAX) return { status: 'SCALE', cls: 'scale' }; if (c.cpr <= CPR_HOLD_MAX) return { status: 'HOLD', cls: 'hold' }; if (c.cpr <= CPR_FIX_MAX) return { status: 'FIX', cls: 'fix' }; return { status: 'KILL', cls: 'kill' }; } function context(all) { var list = Array.isArray(all) ? all : []; var spend = 0, results = 0, impressions = 0; list.forEach(function (c) { spend += c.spend; results += c.results; impressions += c.impressions; }); return { acctCpr: results > 0 ? spend / results : 0, avgImpr: list.length ? impressions / list.length : 0 }; } function ctrVerdict(c) { if (c.ctr >= CTR_STRONG) return { cls: 'good', text: 'CTR ' + c.ctr.toFixed(2) + '% is strong (≥ ' + CTR_STRONG + '%) — the hook, image and headline are earning clicks.' }; if (c.ctr >= CTR_OK) return { cls: 'warn', text: 'CTR ' + c.ctr.toFixed(2) + '% is acceptable (' + CTR_OK + '–' + CTR_STRONG + '%) — the creative gets clicks but is not a thumb-stopper.' }; return { cls: 'bad', text: 'CTR ' + c.ctr.toFixed(2) + '% is weak (< ' + CTR_OK + '%) — people are seeing it but not clicking, so the hook/visual is the bottleneck.' }; } function cprLine(c, ctx) { if (c.results === 0) return '0 bookings from ' + money(c.spend) + ' spend — no cost per result yet (account average is ' + money(ctx.acctCpr) + ').'; var diff = pctDiff(c.cpr, ctx.acctCpr); var vsAcct = ctx.acctCpr > 0 ? ' (' + (diff <= 0 ? Math.abs(diff) + '% cheaper' : diff + '% dearer') + ' than the 60-day account average of ' + money(ctx.acctCpr) + ')' : ''; return c.results + ' booking' + (c.results === 1 ? '' : 's') + ' from ' + money(c.spend) + ' spend = CPR ' + money(c.cpr) + vsAcct + '.'; } function volumeLine(c, ctx) { var rel = pctDiff(c.impressions, ctx.avgImpr); var where = rel > VOLUME_BAND * 100 ? 'above' : rel < -VOLUME_BAND * 100 ? 'below' : 'around'; return c.impressions.toLocaleString() + ' impressions — ' + where + ' the 60-day creative average (' + Math.round(ctx.avgImpr).toLocaleString() + ').'; } function ageLine(c) { if (c.days_active == null) return ''; return 'Running ' + c.days_active + ' days (first ad created ' + c.launched + ').'; } function fatigueNote(c) { return c.days_active != null && c.days_active > FATIGUE_DAYS ? ' It has run ' + FATIGUE_DAYS + '+ days, so watch frequency/CTR weekly for fatigue before adding budget.' : ''; } function scaleAdvice(c, ctx) { return { why: 'Every booking this creative buys costs ' + money(c.cpr) + ', under the ' + money(CPR_SCALE_MAX) + ' scale line' + (ctx.acctCpr > 0 && c.cpr < ctx.acctCpr ? ' and cheaper than the account average' : '') + '. Extra budget here should keep buying bookings at the best rate in the account, and ' + c.results + ' results in 60 days means that CPR is not a small-sample fluke.' + fatigueNote(c), heading: 'How to scale', actions: [ 'Raise budget on the ad set(s) carrying it by 20–30% every 3–4 days while CPR stays under ' + money(CPR_SCALE_MAX) + ' — bigger jumps reset learning.', 'Duplicate the creative into clinic ad sets where it is not yet running (same copy, local clinic name).', c.ctr < CTR_STRONG ? 'CTR is not yet strong — try a headline/thumbnail variant to lift click rate while the conversion economics hold.' : 'Keep the hook intact; test only minor variants (background colour, CTA) so you do not break what works.', 'Pull back if CPR climbs past ' + money(CPR_HOLD_MAX) + ' for 5+ days after scaling.' ] }; } function holdAdvice(c) { return { why: 'CPR ' + money(c.cpr) + ' sits in the ' + money(CPR_SCALE_MAX) + '–' + money(CPR_HOLD_MAX) + ' band: profitable enough to keep running, but not cheap enough that more budget is the best use of money versus SCALE creatives.' + fatigueNote(c), heading: 'What to do', actions: [ 'Keep budget flat; let SCALE creatives take any new spend.', c.ctr < CTR_OK ? 'Weak CTR — a stronger hook/visual could push this into SCALE territory.' : 'Clicks are fine — a landing-page or offer tweak is the lever to lower CPR.', 'Re-check in 7 days; promote to SCALE if CPR drops under ' + money(CPR_SCALE_MAX) + '.' ] }; } function fixAdvice(c, ctx) { var postClick = c.ctr >= CTR_OK; var why = 'CPR ' + money(c.cpr) + ' is in the ' + money(CPR_HOLD_MAX) + '–' + money(CPR_FIX_MAX) + ' band — bookings cost ' + pctDiff(c.cpr, ctx.acctCpr) + '% more than the account average, so it is burning budget relative to your winners, ' + 'but it is still converting, which is why it is FIX rather than KILL. ' + (postClick ? 'Diagnosis: the CLICK side is fine (CTR ' + c.ctr.toFixed(2) + '%), so the leak is after the click — the landing page, booking flow, or who the ad set is reaching.' : 'Diagnosis: the CREATIVE is the problem — CTR ' + c.ctr.toFixed(2) + '% means few people click, so each booking needs far more impressions and spend.'); var actions = postClick ? [ 'Open the ad’s landing page on mobile: check load time, that the Calendly/booking CTA is above the fold, and that the page promise matches the ad promise.', 'Compare this creative’s ad sets: if one clinic/audience is dragging CPR up, pause that placement rather than the creative.', 'Test a clearer CTA/offer in the ad copy (e.g. "Medicare-rebated · book a 15-min call") so clicks are more qualified.', 'Give it 7 days / ~' + money(Math.max(200, c.cpr * 3)) + ' more spend; if CPR is still over ' + money(CPR_HOLD_MAX) + ', pause and move budget to a SCALE creative.' ] : [ 'Swap the hook: new headline + first-3-seconds visual (keep targeting and landing page unchanged so you isolate the creative).', 'Borrow the structure of your SCALE creatives (one concrete visual metaphor for the benefit, clinic + Medicare badges visible).', 'Launch 2–3 variants and let Meta pick; kill the original if a variant beats it on CTR after ~3,000 impressions.', 'If CTR stays under ' + CTR_OK + '% after the refresh, retire the concept.' ]; return { why: why, heading: 'What to fix', actions: actions }; } function killAdvice(c, ctx) { return { why: 'CPR ' + money(c.cpr) + ' is over the ' + money(CPR_FIX_MAX) + ' kill line — each booking costs ' + pctDiff(c.cpr, ctx.acctCpr) + '% more than the account average. Fixing rarely recovers a gap that large; the budget earns more on a SCALE creative.', heading: 'What to do', actions: [ 'Pause the ads using this creative.', 'Move the freed daily budget to the top SCALE creative.', 'Keep the asset in the Worst Performing Ads folder as a reference of what not to repeat.' ] }; } function noDataAdvice(c) { var clicksOk = c.ctr >= CTR_OK; return { why: money(c.spend) + ' spent in 60 days with zero bookings, so CPR cannot be computed. ' + (clicksOk ? 'Clicks are arriving (CTR ' + c.ctr.toFixed(2) + '%), so the booking step after the click is failing.' : 'Few clicks and no bookings — the creative is not resonating.'), heading: 'What to do', actions: [ clicksOk ? 'Check the landing page, pixel firing and Calendly link before spending more.' : 'Pause it; the creative is not earning clicks.', 'If spend is under ~$100 it may simply be too early — re-check after another $100.' ] }; } function explain(c, all) { var ctx = context(all); var status = statusFor(c).status; var ctr = ctrVerdict(c); var advice = status === 'SCALE' ? scaleAdvice(c, ctx) : status === 'HOLD' ? holdAdvice(c) : status === 'FIX' ? fixAdvice(c, ctx) : status === 'KILL' ? killAdvice(c, ctx) : noDataAdvice(c); var evidence = [cprLine(c, ctx), ctr.text, volumeLine(c, ctx), ageLine(c)].filter(Boolean); return '

' + status + ' — ' + esc(c.name) + '

' + '

' + advice.why + '

' + '' + '' + advice.heading + '' + '' + '
Status is a rule on 60-day cost per result (Calendly booking): SCALE ≤ ' + money(CPR_SCALE_MAX) + ' · HOLD ≤ ' + money(CPR_HOLD_MAX) + ' · FIX ≤ ' + money(CPR_FIX_MAX) + ' · KILL above. ' + 'Not a prediction — it reads the Meta Ads numbers in this row.
'; } function ensurePopover() { var pop = document.getElementById(POPOVER_ID); if (pop) return pop; var style = document.createElement('style'); style.id = POPOVER_ID + '-css'; style.textContent = CSS; document.head.appendChild(style); pop = document.createElement('div'); pop.id = POPOVER_ID; pop.setAttribute('role', 'tooltip'); document.body.appendChild(pop); return pop; } function place(pop, badge) { var r = badge.getBoundingClientRect(); pop.classList.add('show'); var w = pop.offsetWidth, h = pop.offsetHeight, gap = 8, margin = 12; var left = Math.max(margin, Math.min(r.left, window.innerWidth - w - margin)); var top = r.bottom + gap; if (top + h > window.innerHeight - margin) top = Math.max(margin, r.top - h - gap); pop.style.left = left + 'px'; pop.style.top = top + 'px'; } function attach(opts) { var root = opts.root || document; var selector = opts.selector; var resolve = opts.resolve; var render = opts.render; var pop = ensurePopover(); var html = function (el) { if (render) return render(el); var r = resolve(el); return r && r.creative ? explain(r.creative, r.all) : null; }; var show = function (el) { var out = html(el); if (out == null) return; pop.style.width = (opts.width || 360) + 'px'; pop.innerHTML = out; place(pop, el); }; var hide = function () { pop.classList.remove('show'); }; var onEnter = function (e) { var b = e.target.closest && e.target.closest(selector); if (b) show(b); }; var onLeave = function (e) { if (e.target.closest && e.target.closest(selector)) hide(); }; root.addEventListener('mouseover', onEnter); root.addEventListener('mouseout', onLeave); root.addEventListener('focusin', onEnter); root.addEventListener('focusout', onLeave); window.addEventListener('scroll', hide, { passive: true }); return { show: show, hide: hide }; } global.CreativeStatusExplainer = { THRESHOLDS: { CPR_SCALE_MAX: CPR_SCALE_MAX, CPR_HOLD_MAX: CPR_HOLD_MAX, CPR_FIX_MAX: CPR_FIX_MAX, CTR_STRONG: CTR_STRONG, CTR_OK: CTR_OK }, statusFor: statusFor, explain: explain, attach: attach }; })(window);