/* 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 (
{fmt(total,'currency')}
total spend
{segs.map(d => (
{d.label}
{(d.frac*100).toFixed(1)}%
))}
);
}
Object.assign(window, { SpendRoasChart, PlatformShare });