/* Report Builder — metric registry, aggregation, i18n + color engine ===== THE single source of truth for every number a report shows or exports. Iron rule: ratio metrics are NEVER averaged — always recomputed from summed numerators/denominators at the displayed aggregation level. Real data only: everything derives from /api/report/dataset sums. (See REPORT_BUILDER_SPEC.md. Do not use metrics.jsx fullMetrics/buildAds here — those are synthetic-era generators.) ======================================================================== */ /* ---------- Platforms (report ordering is a locked product decision) --- */ const REPORT_PLATFORMS = [ { id:'meta', label:'Meta', ar:'ميتا', icon:'meta', tone:'#2f7bff' }, { id:'google', label:'Google Ads', ar:'إعلانات جوجل', icon:'google', tone:'#34A853' }, { id:'tiktok', label:'TikTok Ads', ar:'تيك توك', icon:'tiktok', tone:'#111111' }, { id:'snapchat', label:'Snapchat Ads', ar:'سناب شات', icon:'snapchat', tone:'#F7C700' }, { id:'x', label:'X Ads', ar:'منصة إكس', icon:'x', tone:'#111111' }, { id:'linkedin', label:'LinkedIn Ads', ar:'لينكد إن', icon:'linkedin', tone:'#0A66C2' }, ]; const REPORT_PLATFORM_ORDER = REPORT_PLATFORMS.map(p=>p.id); const rPlatform = (id)=> REPORT_PLATFORMS.find(p=>p.id===id) || { id, label:id, ar:id, icon:'custom', tone:'#8b8b80' }; /* Official platform logo PNGs (user-supplied, report_assets/platforms/). Used on slides + exports; the SVG BrandIcon set stays as fallback. */ const R_PLATFORM_LOGOS = { meta:'report_assets/platforms/meta.png', google:'report_assets/platforms/google.png', tiktok:'report_assets/platforms/tiktok.png', snapchat:'report_assets/platforms/snapchat.png', x:'report_assets/platforms/x.png', linkedin:'report_assets/platforms/linkedin.png', facebook:'report_assets/platforms/facebook.png', instagram:'report_assets/platforms/instagram.png', whatsapp:'report_assets/platforms/whatsapp.png', youtube:'report_assets/platforms/youtube.png', }; const rPlatformLogo = (id)=> R_PLATFORM_LOGOS[id] || null; /* Sort any platform list into the locked order (unknowns last, alphabetical) */ function rSortPlatforms(ids){ return [...ids].sort((a,b)=>{ const ia=REPORT_PLATFORM_ORDER.indexOf(a), ib=REPORT_PLATFORM_ORDER.indexOf(b); return (ia<0?99:ia)-(ib<0?99:ib) || String(a).localeCompare(String(b)); }); } /* ---------- Metric registry ------------------------------------------- */ /* agg: 'sum' | 'last' | {num, den, mult} (ratio: sum(num)/sum(den)*mult) fmt: currency | currency2 | pct | x | int | freq good: 'up' | 'down' | null (no judgement) tier: 1 core (all platforms) | 2 platform-specific | 3 exec/brand platforms: undefined = all */ const REPORT_METRICS = [ { key:'spend', label:'Spend', ar:'التكلفة الإجمالية', fmt:'currency', agg:'sum', good:null, tier:1 }, { key:'impressions', label:'Impressions', ar:'الظهور', fmt:'int', agg:'sum', good:'up', tier:1 }, { key:'reach', label:'Reach', ar:'الوصول', fmt:'int', agg:'sum', good:'up', tier:1, note:'Summed daily reach — may overcount unique users across days.' }, { key:'clicks', label:'Clicks', ar:'النقرات', fmt:'int', agg:'sum', good:'up', tier:1 }, { key:'link_clicks', label:'Link clicks', ar:'نقرات الرابط', fmt:'int', agg:'sum', good:'up', tier:2 }, { key:'conversions', label:'Results', ar:'النتائج', fmt:'int', agg:'sum', good:'up', tier:1 }, { key:'revenue', label:'Conv. value', ar:'قيمة التحويلات', fmt:'currency', agg:'sum', good:'up', tier:1 }, { key:'video_views', label:'Video views', ar:'مشاهدات الفيديو', fmt:'int', agg:'sum', good:'up', tier:2 }, { key:'engagements', label:'Engagements', ar:'التفاعلات', fmt:'int', agg:'sum', good:'up', tier:2 }, { key:'leads', label:'Leads', ar:'العملاء المحتملون', fmt:'int', agg:'sum', good:'up', tier:2 }, /* Ratios — recomputed, never averaged */ { key:'ctr', label:'CTR', ar:'نسبة النقر', fmt:'pct', agg:{num:'clicks',den:'impressions',mult:100}, good:'up', tier:1 }, { key:'cpc', label:'CPC', ar:'متوسط تكلفة النقرة',fmt:'currency2', agg:{num:'spend',den:'clicks'}, good:'down', tier:1 }, { key:'cpm', label:'CPM', ar:'تكلفة الألف ظهور', fmt:'currency2', agg:{num:'spend',den:'impressions',mult:1000}, good:'down', tier:1 }, { key:'cpa', label:'Cost / result', ar:'تكلفة النتيجة', fmt:'currency2', agg:{num:'spend',den:'conversions'}, good:'down', tier:1 }, { key:'cvr', label:'Conv. rate', ar:'معدل التحويل', fmt:'pct', agg:{num:'conversions',den:'clicks',mult:100}, good:'up', tier:1 }, { key:'roas', label:'ROAS', ar:'العائد على الإنفاق',fmt:'x', agg:{num:'revenue',den:'spend'}, good:'up', tier:1 }, { key:'frequency', label:'Frequency', ar:'التكرار', fmt:'freq', agg:{num:'impressions',den:'reach'}, good:null, tier:1 }, { key:'cpl', label:'Cost / lead', ar:'تكلفة العميل المحتمل', fmt:'currency2', agg:{num:'spend',den:'leads'}, good:'down', tier:2 }, { key:'eng_rate', label:'Engagement rate', ar:'معدل التفاعل', fmt:'pct', agg:{num:'engagements',den:'impressions',mult:100}, good:'up', tier:2 }, { key:'cpv', label:'Cost / video view',ar:'تكلفة المشاهدة', fmt:'currency2', agg:{num:'spend',den:'video_views'}, good:'down', tier:2 }, /* Brand (exec summary) */ { key:'followers_gained', label:'Followers gained', ar:'متابعون جدد', fmt:'int', agg:'sum', good:'up', tier:3 }, { key:'followers', label:'Total followers', ar:'إجمالي المتابعين', fmt:'int', agg:'last', good:'up', tier:3 }, ]; const REPORT_BASE_KEYS = REPORT_METRICS.filter(m=>m.agg==='sum' && m.tier!==3).map(m=>m.key); let _customMetrics = []; // injected per account at runtime let _blockedKeys = []; // per-account blocklist (e.g. Al Munawarah: roas) function rMetric(key){ return REPORT_METRICS.find(m=>m.key===key) || _customMetrics.find(m=>m.key===key) || null; } /* Pickers/lists exclude account-blocked metrics (set via rLoadCustomMetrics). */ function rAllMetrics(){ return [...REPORT_METRICS, ..._customMetrics].filter(m=>!_blockedKeys.includes(m.key)); } function rMetricBlockedFor(accountId, key){ const bl = (window.ACCOUNT_METRIC_BLOCKLIST || {})[accountId]; return !!bl && bl.includes(String(key||'').toLowerCase()); } /* Executive Summary default scorecards (product decision 2026-07-10): Reach, Impressions, Cost/Result, Clicks, CPC, CTR, Total Followers, Video views. */ function rDefaultExecMetrics(accountId){ return ['reach','impressions','cpa','clicks','cpc','ctr','followers','video_views'] .filter(k=>!rMetricBlockedFor(accountId, k)); } /* Custom metrics: {key,label,ar?,fmt,formula} — formula over base/derived keys, e.g. "spend / engagements". Stored per account (localStorage; columnBuilder pattern). */ function rLoadCustomMetrics(accountId){ _blockedKeys = ((window.ACCOUNT_METRIC_BLOCKLIST || {})[accountId] || []).map(k=>String(k).toLowerCase()); try { const raw = JSON.parse(localStorage.getItem('relay_report_custom_'+accountId) || '[]'); _customMetrics = (Array.isArray(raw)?raw:[]).filter(m=>m && m.key && m.formula).map(m=>({ key:String(m.key), label:String(m.label||m.key), ar:m.ar||m.label||m.key, fmt:['currency','currency2','pct','x','int'].includes(m.fmt)?m.fmt:'currency2', agg:{formula:String(m.formula)}, good:m.good==='down'?'down':(m.good==='up'?'up':null), tier:2, custom:true, })); } catch(e){ _customMetrics = []; } return _customMetrics; } function rSaveCustomMetric(accountId, m){ const list = rLoadCustomMetrics(accountId).filter(x=>x.key!==m.key); list.push(m); localStorage.setItem('relay_report_custom_'+accountId, JSON.stringify(list.map(x=>({key:x.key,label:x.label,ar:x.ar,fmt:x.fmt,formula:x.agg.formula,good:x.good})))); return rLoadCustomMetrics(accountId); } /* ---------- Aggregation ------------------------------------------------ */ const rZeroSums = ()=> Object.fromEntries(REPORT_BASE_KEYS.map(k=>[k,0])); /* Sum base metric fields across an array of fact rows/objects. */ function rSum(rows){ const out = rZeroSums(); (rows||[]).forEach(r=>{ REPORT_BASE_KEYS.forEach(k=>{ out[k] += Number(r && r[k]) || 0; }); }); return out; } /* Safe formula evaluator for custom metrics (numbers + + - * / ( ) only). */ function rEvalFormula(formula, sums){ const expr = String(formula).replace(/[a-z_][a-z0-9_]*/gi, (name)=>{ const v = rMetricValue(sums, name); return Number.isFinite(v) ? String(v) : '0'; }); if (!/^[-+*/().\d\s]+$/.test(expr)) return 0; try { const v = Function('"use strict";return (' + expr + ')')(); return Number.isFinite(v)?v:0; } catch(e){ return 0; } } /* Value of ANY metric key from a base-sums object. Ratios recomputed here. */ function rMetricValue(sums, key){ if (!sums) return 0; if (key in sums && typeof sums[key] === 'number') return sums[key]; const m = rMetric(key); if (!m) return 0; if (m.agg === 'sum' || m.agg === 'last') return Number(sums[key]) || 0; if (m.agg.formula) return rEvalFormula(m.agg.formula, sums); const num = Number(sums[m.agg.num]) || 0, den = Number(sums[m.agg.den]) || 0; if (den <= 0) return 0; return num / den * (m.agg.mult || 1); } /* % delta + tone. good-direction aware (CPA down = positive). */ function rDelta(cur, prev, key){ const m = rMetric(key); if (!Number.isFinite(cur) || !Number.isFinite(prev) || prev === 0) return null; const pct = (cur - prev) / Math.abs(prev) * 100; const good = m && m.good ? m.good : null; const tone = !good || Math.abs(pct) < 0.05 ? 'neutral' : ((pct > 0) === (good === 'up') ? 'good' : 'bad'); return { pct, up: pct > 0, tone }; } /* ---------- Dataset selectors (input = /api/report/dataset payload) ---- */ function rDatasetPlatforms(ds){ const set = new Set(); (ds && ds.daily || []).forEach(r=>set.add(r.platform)); (ds && ds.entities && ds.entities.campaigns || []).forEach(c=>set.add(c.platform)); return rSortPlatforms([...set]); } /* Totals over the whole scope (or one platform), cur + prev. */ function rTotals(ds, platform){ const pick = (rows)=> platform ? rows.filter(r=>r.platform===platform) : rows; return { cur: rSum(pick(ds.daily || [])), prev: rSum(pick(ds.dailyCompare || [])), }; } /* Per-platform totals in locked order: [{platform, cur, prev}] */ function rByPlatform(ds){ return rDatasetPlatforms(ds).map(p=>({ platform:p, ...rTotals(ds, p) })); } /* Entity rows for a level with attached cur/prev, filter/sort/topN. opts: {platform, sortKey, sortDir, topN, filters:[{key,op,value}]} */ function rEntityRows(ds, level, opts){ opts = opts || {}; const src = level==='adset' ? (ds.entities.adsets||[]) : level==='ad' ? (ds.entities.ads||[]) : (ds.entities.campaigns||[]); let rows = src.filter(e=> (Number(e.cur && e.cur.spend)||0) > 0 || (Number(e.cur && e.cur.impressions)||0) > 0); if (opts.platform) rows = rows.filter(e=>e.platform===opts.platform); (opts.filters||[]).forEach(f=>{ if (!f || !f.key) return; rows = rows.filter(e=>{ const v = rMetricValue(e.cur, f.key); const t = Number(f.value); if (f.op==='gte') return v >= t; if (f.op==='lte') return v <= t; if (f.op==='contains') return String(e.name||'').toLowerCase().includes(String(f.value).toLowerCase()); return true; }); }); const sk = opts.sortKey || 'spend', dir = opts.sortDir === 'asc' ? 1 : -1; rows = [...rows].sort((a,b)=> dir * (rMetricValue(a.cur, sk) - rMetricValue(b.cur, sk))); if (opts.topN && rows.length > opts.topN) { const top = rows.slice(0, opts.topN); const rest = rows.slice(opts.topN); const others = { id:'__others', name:`Others (${rest.length})`, platform:opts.platform||'', cur:rSum(rest.map(r=>r.cur)), prev:rSum(rest.map(r=>r.prev)), _others:true }; return { rows: top, others, total: rows.length }; } return { rows, others:null, total: rows.length }; } /* Daily time series of metric keys (whole scope or one platform). Returns [{date, label, : value, _cmp_: comparisonValue}] — comparison rows aligned by index (day 1 ↔ day 1). */ function rSeries(ds, keys, platform){ const group = (rows)=>{ const by = {}; (rows||[]).forEach(r=>{ if (platform && r.platform !== platform) return; (by[r.date] = by[r.date] || []).push(r); }); return Object.keys(by).sort().map(d=>{ const sums = rSum(by[d]); const o = { date:d }; keys.forEach(k=>{ o[k] = +rMetricValue(sums,k); }); return o; }); }; const cur = group(ds.daily), prev = group(ds.dailyCompare); return cur.map((r,i)=>{ const o = { ...r, label: new Date(r.date+'T00:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'}) }; keys.forEach(k=>{ o['_cmp_'+k] = prev[i] ? prev[i][k] : null; }); return o; }); } /* Followers: gained per platform in-range + latest totals. */ function rBrand(ds){ const gained = {}; (ds.brand && ds.brand.rows || []).forEach(r=>{ if (ds.range && (r.date < ds.range.start || r.date > ds.range.end)) return; gained[r.platform] = (gained[r.platform]||0) + (Number(r.followers_gained)||0); }); const latest = (ds.brand && ds.brand.latest) || {}; const platforms = rSortPlatforms([...new Set([...Object.keys(gained), ...Object.keys(latest)])]); return { platforms, gained, latest, totalGained: Object.values(gained).reduce((a,b)=>a+b,0), totalFollowers: Object.values(latest).reduce((a,b)=>a+(Number(b)||0),0), hasData: platforms.length > 0, }; } /* ---------- Formatting (report layer — NEVER data.jsx's implicit $) ---- */ const R_AR_DIGITS = false; // Western digits (matches client's own deck) — v1.5 option function rFmt(v, fmtKind, currency, lang){ if (v === null || v === undefined || Number.isNaN(v)) return '—'; const n = Number(v); const money = (dec)=>{ const s = n.toLocaleString('en-US',{minimumFractionDigits:dec,maximumFractionDigits:dec}); if (lang==='ar' && (currency||'').toUpperCase()==='SAR') return s + ' ريال'; const sym = { USD:'$', EUR:'€', GBP:'£' }[(currency||'USD').toUpperCase()]; return sym ? sym + s : (currency||'USD').toUpperCase() + ' ' + s; }; switch (fmtKind){ case 'currency': return money(0); case 'currency2': return money(2); case 'pct': return n.toFixed(2) + '%'; case 'x': return n.toFixed(2) + '×'; case 'freq': return n.toFixed(2); case 'int': return Math.round(n).toLocaleString('en-US'); default: return String(v); } } /* Compact display for scorecards: 930K / 1.2M — Arabic unit words for ar. */ function rCompact(v, fmtKind, currency, lang){ const n = Number(v)||0; const abs = Math.abs(n); const unit = abs>=1e6 ? [1e6, lang==='ar'?' مليون':'M'] : abs>=1e4 ? [1e3, lang==='ar'?' ألف':'K'] : null; const isMoney = fmtKind==='currency'||fmtKind==='currency2'; if (!unit) return rFmt(v, fmtKind, currency, lang); const num = (n/unit[0]).toLocaleString('en-US',{maximumFractionDigits:1}); const core = num + unit[1]; if (!isMoney) return core; if (lang==='ar' && (currency||'').toUpperCase()==='SAR') return core + ' ريال'; const sym = { USD:'$', EUR:'€', GBP:'£' }[(currency||'USD').toUpperCase()]; return sym ? sym + core : (currency||'USD').toUpperCase() + ' ' + core; } function rMetricLabel(key, lang){ const m = rMetric(key); if (!m) return key; if (lang === 'both') return (m.ar || m.label) + ' · ' + m.label; return lang==='ar' ? (m.ar || m.label) : m.label; } /* ---------- Static label i18n (RTL-lite v1) ---------------------------- */ const REPORT_STR = { agenda: { en:'Agenda', ar:'جدول المحتوى' }, exec_summary: { en:'Executive Summary', ar:'الملخص التنفيذي' }, kpis: { en:'Key Performance Indicators', ar:'المؤشرات الرئيسية للأداء' }, thank_you: { en:'Thank You', ar:'شكراً لكم' }, prepared_by: { en:'Prepared by', ar:'إعداد' }, period: { en:'Reporting period', ar:'الفترة' }, performance_report:{ en:'Performance Report', ar:'تقرير الأداء' }, campaigns: { en:'Campaigns', ar:'الحملات' }, adsets: { en:'Ad Sets', ar:'المجموعات الإعلانية' }, ads: { en:'Ads', ar:'الإعلانات' }, vs_prev: { en:'vs previous period', ar:'مقارنة بالفترة السابقة' }, spend_share: { en:'Spend share', ar:'توزيع الإنفاق' }, followers_by_platform:{ en:'Followers gained', ar:'متابعون جدد' }, total_followers: { en:'Combined followers', ar:'إجمالي المتابعين' }, others: { en:'Others', ar:'أخرى' }, no_data: { en:'No data in this range', ar:'لا توجد بيانات في هذه الفترة' }, next_steps: { en:'Key Priorities & Next Steps', ar:'الأولويات والخطوات القادمة' }, highlights: { en:'Highlights', ar:'أبرز النتائج' }, overview: { en:'Account Overview', ar:'نظرة عامة على الحساب' }, trends: { en:'Performance Trends', ar:'اتجاهات الأداء' }, daily: { en:'Daily performance', ar:'الأداء اليومي' }, top_campaigns: { en:'Top campaigns', ar:'أفضل الحملات' }, }; /* lang: 'en' | 'ar' | 'both' (bilingual: Arabic first, then English) */ function rStr(key, lang){ const s = REPORT_STR[key]; if (!s) return key; if (lang === 'both') return s.ar + ' · ' + s.en; return lang === 'ar' ? s.ar : s.en; } function rDateLabel(iso, lang){ const d = new Date(iso + 'T00:00:00'); const ar = lang === 'ar' || lang === 'both'; return d.toLocaleDateString(ar ? 'ar-SA-u-ca-gregory-nu-latn' : 'en-US', { year:'numeric', month:'long', day:'numeric' }); } /* ---------- Color engine (contrast + tonal ramp) ----------------------- */ function rHexRgb(hex){ const h = String(hex||'').replace('#',''); if (h.length < 6) return [0,0,0]; return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; } function rRgbHex(r,g,b){ const c = (x)=> Math.max(0,Math.min(255,Math.round(x))).toString(16).padStart(2,'0'); return '#' + c(r) + c(g) + c(b); } /* WCAG relative luminance → pick readable text color over any background. */ function rLuminance(hex){ const [r,g,b] = rHexRgb(hex).map(v=>{ const s = v/255; return s <= 0.03928 ? s/12.92 : Math.pow((s+0.055)/1.055, 2.4); }); return 0.2126*r + 0.7152*g + 0.0722*b; } function rContrastText(bgHex){ return rLuminance(bgHex) > 0.35 ? '#1a1a17' : '#ffffff'; } function rMix(hexA, hexB, t){ const a = rHexRgb(hexA), b = rHexRgb(hexB); return rRgbHex(a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t); } /* Expand 3 brand seeds into an n-series chart palette (tints/shades). */ function rRamp(seeds, n){ const base = (seeds && seeds.length ? seeds : ['#233433','#45464B','#488D82']).slice(0,3); const out = [...base]; let i = 0; while (out.length < (n||8)){ const seed = base[i % base.length]; const step = Math.floor(i / base.length) + 1; out.push(step % 2 === 1 ? rMix(seed, '#ffffff', Math.min(0.25*step, 0.7)) : rMix(seed, '#000000', Math.min(0.18*step, 0.6))); i++; } return out.slice(0, n||8); } /* ---------- Rule-based auto-insights (editable text seeds) ------------- */ function rAutoInsights(ds, lang, currency){ const out = []; /* bilingual: push the Arabic bullet, then its English twin */ const L = (en, ar)=> lang==='both' ? (out.push(ar), en) : (lang==='ar' ? ar : en); const t = rTotals(ds); const spend = rMetricValue(t.cur,'spend'); if (spend <= 0) return out; const d = rDelta(spend, rMetricValue(t.prev,'spend'), 'spend'); if (d) out.push(L( `Total spend ${d.up?'increased':'decreased'} ${Math.abs(d.pct).toFixed(1)}% vs the previous period (${rFmt(spend,'currency',currency,lang)}).`, `${d.up?'ارتفع':'انخفض'} إجمالي الإنفاق بنسبة ${Math.abs(d.pct).toFixed(1)}٪ مقارنة بالفترة السابقة (${rFmt(spend,'currency',currency,lang)}).`)); /* biggest platform mover by spend share */ const plats = rByPlatform(ds); if (plats.length > 1){ let best = null; plats.forEach(p=>{ const dd = rDelta(rMetricValue(p.cur,'spend'), rMetricValue(p.prev,'spend'), 'spend'); if (dd && (!best || Math.abs(dd.pct) > Math.abs(best.d.pct))) best = { p, d: dd }; }); if (best && Math.abs(best.d.pct) >= 10){ const nm = lang==='ar' ? rPlatform(best.p.platform).ar : rPlatform(best.p.platform).label; out.push(L( `${nm} was the biggest mover: spend ${best.d.up?'up':'down'} ${Math.abs(best.d.pct).toFixed(0)}% period-over-period.`, `${nm} سجلت أكبر تغيّر: الإنفاق ${best.d.up?'ارتفع':'انخفض'} بنسبة ${Math.abs(best.d.pct).toFixed(0)}٪ مقارنة بالفترة السابقة.`)); } } /* CPA outliers among campaigns with meaningful spend */ const { rows } = rEntityRows(ds, 'campaign', {}); const eligible = rows.filter(r=> rMetricValue(r.cur,'spend') >= Math.max(spend*0.05, 100) && rMetricValue(r.cur,'conversions') > 0); if (eligible.length >= 2){ const byCpa = [...eligible].sort((a,b)=> rMetricValue(a.cur,'cpa') - rMetricValue(b.cur,'cpa')); const bestC = byCpa[0], worstC = byCpa[byCpa.length-1]; out.push(L( `Best cost-efficiency: “${bestC.name}” at ${rFmt(rMetricValue(bestC.cur,'cpa'),'currency2',currency,lang)} per result; “${worstC.name}” is the most expensive at ${rFmt(rMetricValue(worstC.cur,'cpa'),'currency2',currency,lang)}.`, `أفضل كفاءة تكلفة: «${bestC.name}» بتكلفة ${rFmt(rMetricValue(bestC.cur,'cpa'),'currency2',currency,lang)} لكل نتيجة؛ بينما «${worstC.name}» الأعلى تكلفة عند ${rFmt(rMetricValue(worstC.cur,'cpa'),'currency2',currency,lang)}.`)); } const ctr = rMetricValue(t.cur,'ctr'), pctr = rMetricValue(t.prev,'ctr'); const dctr = rDelta(ctr, pctr, 'ctr'); if (dctr && Math.abs(dctr.pct) >= 5) out.push(L( `Blended CTR ${dctr.up?'improved to':'declined to'} ${ctr.toFixed(2)}% (${dctr.up?'+':''}${dctr.pct.toFixed(1)}%).`, `نسبة النقر الإجمالية ${dctr.up?'تحسنت إلى':'تراجعت إلى'} ${ctr.toFixed(2)}٪ (${dctr.up?'+':''}${dctr.pct.toFixed(1)}٪).`)); return out.slice(0, lang==='both' ? 8 : 4); } /* ---------- Performance insights (best/worst per level) ---------------- */ /* Objective-aware primary metric: results exist → CPA (lower = better); else clicks exist → CTR (higher = better); else CPM (lower = better). */ function rPrimaryMetric(rows){ const tot = rSum(rows.map(r=>r.cur)); if (rMetricValue(tot,'conversions') > 0) return { key:'cpa', best:'min' }; if (rMetricValue(tot,'clicks') > 0) return { key:'ctr', best:'max' }; return { key:'cpm', best:'min' }; } /* Why is a row good/bad? Compare its ratios against the peer average. */ function rPerfReasons(row, peersSum, peersCount, primary, isBest, lang, currency){ const L = (en, ar)=> lang==='ar' ? ar : (lang==='both' ? ar + ' · ' + en : en); const out = []; const avg = (k)=> rMetricValue(peersSum, k); const mine = (k)=> rMetricValue(row.cur, k); const cmp = (k, goodDir)=>{ const a = avg(k), m = mine(k); if (!(a > 0) || !(m > 0)) return null; const pct = Math.round(Math.abs(m - a) / a * 100); if (pct < 10) return null; const better = goodDir === 'down' ? m < a : m > a; return { k, pct, better }; }; const checks = [ ['cpa','down'], ['ctr','up'], ['cpc','down'], ['cvr','up'], ['cpm','down'] ]; for (const [k, dir] of checks){ const c = cmp(k, dir); if (!c) continue; if (isBest === c.better){ const lbl = rMetricLabel(k, lang); const val = rFmt(mine(k), rMetric(k).fmt, currency, lang); out.push(c.better ? L(`${lbl} ${val} — ${c.pct}% better than average`, `${lbl} ${val} — أفضل من المتوسط بنسبة ${c.pct}٪`) : L(`${lbl} ${val} — ${c.pct}% worse than average`, `${lbl} ${val} — أسوأ من المتوسط بنسبة ${c.pct}٪`)); } if (out.length >= 3) break; } if (!out.length){ const p = rMetric(primary.key); out.push(L( `${p.label} ${rFmt(mine(primary.key), p.fmt, currency, lang)} (${isBest?'best':'weakest'} of the group)`, `${p.ar} ${rFmt(mine(primary.key), p.fmt, currency, lang)} (${isBest?'الأفضل':'الأضعف'} في المجموعة)`)); } return out; } /* Best + worst performer for a level within a platform/report scope. */ function rPerfInsights(ds, level, opts){ opts = opts || {}; const { rows } = rEntityRows(ds, level, { platform: opts.platform, sortKey:'spend', sortDir:'desc' }); const totalSpend = rows.reduce((a,r)=>a + rMetricValue(r.cur,'spend'), 0); /* eligibility: enough spend to judge fairly */ const eligible = rows.filter(r=>rMetricValue(r.cur,'spend') >= Math.max(totalSpend * 0.03, 1)); if (eligible.length < 2) return null; const primary = rPrimaryMetric(eligible); const val = (r)=> rMetricValue(r.cur, primary.key); const scored = eligible.filter(r=>val(r) > 0); if (scored.length < 2) return null; const sorted = [...scored].sort((a,b)=> primary.best==='min' ? val(a)-val(b) : val(b)-val(a)); const best = sorted[0], worst = sorted[sorted.length-1]; if (best.id === worst.id) return null; const lang = opts.lang || 'en', currency = opts.currency || 'USD'; const peersSum = rSum(scored.map(r=>r.cur)); return { primary: primary.key, bestId: best.id, best: { name:best.name, value:val(best), reasons: rPerfReasons(best, peersSum, scored.length, primary, true, lang, currency) }, worst: { name:worst.name, value:val(worst), reasons: rPerfReasons(worst, peersSum, scored.length, primary, false, lang, currency) }, count: scored.length, }; } /* ---------- Structured recommendations (Next Steps slide) -------------- */ /* Rule-based, data-driven action cards: {icon, pri:'high'|'med'|'low', title, why, action} — localized, account-blocklist aware. */ function rRecommendations(ds, lang, currency){ const L = (en, ar)=> lang==='ar' ? ar : (lang==='both' ? ar + '\n' + en : en); const out = []; const t = rTotals(ds); const spend = rMetricValue(t.cur,'spend'); if (spend <= 0) return out; const plats = rByPlatform(ds); const perf = rPerfInsights(ds, 'campaign', { lang, currency }); /* 1. scale the winner */ if (perf){ const bestRow = (rEntityRows(ds,'campaign',{}).rows||[]).find(r=>r.id===perf.bestId); const share = bestRow ? rMetricValue(bestRow.cur,'spend')/spend : 0; if (share > 0 && share < 0.45) out.push({ icon:'trend-up', pri:'high', title: L('Scale the top performer','ضاعف الاستثمار في الأفضل أداءً'), why: L(`“${perf.best.name}” — ${perf.best.reasons[0]||''} with only ${(share*100).toFixed(0)}% of spend.`, `«${perf.best.name}» — ${perf.best.reasons[0]||''} مع ${(share*100).toFixed(0)}٪ فقط من الإنفاق.`), action:L('Shift 15–20% of budget toward it and monitor for 5–7 days.', 'انقل ١٥–٢٠٪ من الميزانية إليه وراقب الأداء لمدة ٥–٧ أيام.'), }); /* 2. fix or pause the weakest */ out.push({ icon:'alert', pri:'high', title: L('Review the weakest performer','عالج الأضعف أداءً'), why: L(`“${perf.worst.name}” — ${perf.worst.reasons[0]||''}.`, `«${perf.worst.name}» — ${perf.worst.reasons[0]||''}.`), action:L('Refresh its creatives/audience or pause and reallocate.', 'حدّث التصاميم/الجمهور أو أوقفه وأعد توزيع ميزانيته.'), }); } /* 3. spend pacing vs previous period */ const dSpend = rDelta(spend, rMetricValue(t.prev,'spend'), 'spend'); if (dSpend && dSpend.pct < -20) out.push({ icon:'zap', pri:'high', title: L('Delivery dropped sharply','انخفاض حاد في الإنفاق'), why: L(`Spend is ${Math.abs(dSpend.pct).toFixed(0)}% below the previous period — check budgets, schedules and disapprovals.`, `الإنفاق أقل بنسبة ${Math.abs(dSpend.pct).toFixed(0)}٪ من الفترة السابقة — راجع الميزانيات والجدولة وحالة الإعلانات.`), action:L('Verify campaign delivery in each ads manager today.', 'تحقق من حالة التسليم في مدير الإعلانات اليوم.'), }); /* 4. platform reallocation */ if (plats.length > 1){ const withCpa = plats.map(p=>({ p:p.platform, cpa:rMetricValue(p.cur,'cpa'), share: rMetricValue(p.cur,'spend')/spend })).filter(x=>x.cpa>0); if (withCpa.length > 1){ const bestP = withCpa.sort((a,b)=>a.cpa-b.cpa)[0]; if (bestP.share < 0.3) out.push({ icon:'columns', pri:'med', title: L('Rebalance the platform mix','أعد توازن توزيع المنصات'), why: L(`${rPlatform(bestP.p).label} has the lowest cost per result (${rFmt(bestP.cpa,'currency2',currency,lang)}) but only ${(bestP.share*100).toFixed(0)}% of spend.`, `${rPlatform(bestP.p).ar} لديها أقل تكلفة نتيجة (${rFmt(bestP.cpa,'currency2',currency,lang)}) لكنها تحصل على ${(bestP.share*100).toFixed(0)}٪ فقط من الإنفاق.`), action:L('Test a gradual budget shift toward it next cycle.', 'جرّب نقل الميزانية تدريجياً نحوها في الدورة القادمة.'), }); } } /* 5. creative fatigue (frequency) */ const freq = rMetricValue(t.cur,'frequency'); if (freq > 3) out.push({ icon:'image', pri:'med', title: L('Creative fatigue risk','خطر إجهاد التصاميم'), why: L(`Average frequency is ${freq.toFixed(1)} — audiences see the same ads repeatedly.`, `متوسط التكرار ${freq.toFixed(1)} — الجمهور يشاهد الإعلانات نفسها مراراً.`), action:L('Rotate in fresh creatives or broaden the audience.', 'أدخل تصاميم جديدة أو وسّع شريحة الجمهور.'), }); /* 6. weak CTR */ const ctr = rMetricValue(t.cur,'ctr'); if (ctr > 0 && ctr < 1) out.push({ icon:'eye', pri:'med', title: L('Hooks need work','معدل النقر يحتاج تحسيناً'), why: L(`Blended CTR is ${ctr.toFixed(2)}% — below the ~1% healthy floor.`, `نسبة النقر الإجمالية ${ctr.toFixed(2)}٪ — أقل من الحد الصحي (~١٪).`), action:L('A/B test first-3-seconds hooks and thumbnails.', 'اختبر افتتاحيات الثواني الثلاث الأولى والصور المصغرة.'), }); /* 7. community momentum */ const br = rBrand(ds); if (br.hasData && br.totalGained > 0) out.push({ icon:'users', pri:'low', title: L('Keep the community momentum','حافظ على نمو المجتمع'), why: L(`+${br.totalGained.toLocaleString('en-US')} followers gained this period across platforms.`, `+${br.totalGained.toLocaleString('en-US')} متابعاً جديداً هذه الفترة عبر المنصات.`), action:L('Sustain the formats driving follows; engage new followers early.', 'واصل المحتوى الذي يجذب المتابعين وتفاعل معهم مبكراً.'), }); return out.slice(0, 6); } Object.assign(window, { REPORT_PLATFORMS, REPORT_PLATFORM_ORDER, REPORT_METRICS, REPORT_STR, rPerfInsights, rRecommendations, rPrimaryMetric, R_PLATFORM_LOGOS, rPlatformLogo, rPlatform, rSortPlatforms, rMetric, rAllMetrics, rLoadCustomMetrics, rSaveCustomMetric, rMetricBlockedFor, rDefaultExecMetrics, rSum, rZeroSums, rMetricValue, rDelta, rTotals, rByPlatform, rEntityRows, rSeries, rBrand, rDatasetPlatforms, rFmt, rCompact, rMetricLabel, rStr, rDateLabel, rContrastText, rLuminance, rMix, rRamp, rHexRgb, rAutoInsights, });