/* Spend vs ROAS chart =============================================== */ const RC = (typeof Recharts !== 'undefined') ? Recharts : window.Recharts; function CustomTooltip({ active, payload, label, dark, hideRoas }){ if (!active || !payload || !payload.length) return null; const data = payload[0]?.payload; const isProj = data?.projected; return (
{data?.label || label}
{isProj && Projected}
Spend
{fmt(data.spend,'currency')}
{!hideRoas && (
ROAS
{data.roas.toFixed(2)}×
)}
); } function SpendRoasChart({ accountId, platformId, range='30d', dark=false, hideRoas=false }){ const data = useMemo(()=>{ const all = timeSeriesFor(accountId, platformId); let actual; if (range==='7d') actual = all.slice(-7); else if (range==='14d') actual = all.slice(-14); else actual = all; const projected = projectedDays(accountId, platformId, 7); return { actual, projected, combined: [...actual, ...projected] }; }, [accountId, platformId, range]); const tone = (PLATFORMS.find(p=>p.id===platformId)||PLATFORMS[0]).tone; const axis = dark ? '#8b8b80' : '#5e5e55'; const grid = dark ? 'rgba(255,255,255,.06)' : 'rgba(0,0,0,.06)'; if (!data.actual.length) return (
No spend data for this range
Connect a data source or import data in Admin → Integrations.
); if (!RC) return
Loading chart…
; const { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, ComposedChart, defs, Legend, ReferenceLine, ReferenceArea } = RC; /* Index where "now" sits (last actual day) */ const nowIdx = data.actual.length - 1; const nowLabel = data.actual[nowIdx]?.label; const projStartLabel = data.projected[0]?.label; const projEndLabel = data.projected[data.projected.length-1]?.label; return (
{/* Projected zone */} {projStartLabel && ( )} curPrefix()+(v>=1000?(v/1000).toFixed(1)+'k':v)}/> {!hideRoas && v.toFixed(1)+'×'}/>} } cursor={{ stroke: tone, strokeOpacity:.4, strokeWidth:1, strokeDasharray:'2 3' }}/> {/* Now line */} {nowLabel && ( )} {/* Actual area */} d.projected ? null : d.spend} name="Spend" stroke={tone} strokeWidth={1.8} fill="url(#gSpend)" animationDuration={900} animationEasing="ease-out" connectNulls={false}/> {/* Projected spend (dashed) */} d.projected ? d.spend : null} name="Spend (projected)" stroke={tone} strokeWidth={1.4} strokeDasharray="5 3" dot={false} animationDuration={900} connectNulls={false}/> {/* Actual ROAS line (dropped entirely for blocklisted accounts) */} {!hideRoas && d.projected ? null : d.roas} name="ROAS" stroke="#10b981" strokeWidth={1.8} dot={false} animationDuration={1100} animationEasing="ease-out" activeDot={{r:4, strokeWidth:0, fill:'#10b981'}} connectNulls={false}/>} {/* Projected ROAS (dashed) */} {!hideRoas && d.projected ? d.roas : null} name="ROAS (projected)" stroke="#10b981" strokeWidth={1.4} strokeDasharray="5 3" dot={false} animationDuration={1100} connectNulls={false}/>}
); } /* Donut share — true pie/donut for clear platform contribution. Excludes blended 'overview', 'google_analytics' (not an ad platform) and 'meta' (its FB/IG children carry the spend). */ function PlatformShare({ accountId }){ const data = useMemo(()=>{ return platformBreakdown(accountId) .map(m=>{ const p = PLATFORMS.find(x=>x.id===m.platform); if (!p || ['overview','google_analytics','meta'].includes(p.id)) return null; return { id:p.id, label:p.short, value:m.spend, tone:p.tone }; }) .filter(Boolean) .sort((a,b)=>b.value-a.value); }, [accountId]); const total = data.reduce((s,d)=>s+d.value, 0) || 1; // build donut arc stops const R=42, C=2*Math.PI*R; let acc=0; const segs = data.map(d=>{ const frac=d.value/total; const seg={ ...d, frac, dash:frac*C, offset:-acc*C }; acc+=frac; return seg; }); return (
{segs.map(s=>( {s.label} · {fmt(s.value,'currency')} ))}
{fmt(total,'currency')}
total spend
{segs.map(d => (
{d.label}
{(d.frac*100).toFixed(1)}%
))}
); } Object.assign(window, { SpendRoasChart, PlatformShare });