Files
justvitamin/static/dashboard/index.html
Omair Saleh 3cd296f6bf feat: dynamic data insights + remaining audit fixes
DATA FIXES:
- Dashboard insights now fully dynamic per date range
  - Revenue/orders/newcust/AOV trend (splits data in half, compares)
  - Channel concentration % computed from filtered data
  - Repeat revenue % with date-range label
  - All insights adapt when user changes date filter/preset
- Offer page charts now load from API (not hardcoded arrays)
  - Revenue chart, new customer chart, channel donut = live data
  - Hero stats (-84%, -42%, 85%) computed dynamically from API
  - ROI calculator AOV pulled from latest year's actual data

REMAINING AUDIT FIXES:
- Fix #4: 'Built by' section on offer page (name, bio, contact)
- Fix #9: Before/After comparison block on homepage
  - Side-by-side: current JV PDP flaws vs AI engine output
  - 8 specific ✗/✓ comparison points
- Fix #6: Before/after serves as instant proof (no 90s wait)

All 10 audit fixes now implemented.
2026-03-02 22:16:29 +08:00

980 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JV Ecommerce — Dynamic Performance Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root{--bg:#0a0e1a;--card:#111827;--card2:#1a2235;--border:#1e293b;--text:#e2e8f0;--muted:#64748b;--accent:#6366f1;--accent2:#818cf8;--green:#10b981;--red:#ef4444;--yellow:#f59e0b;--purple:#a855f7;--cyan:#06b6d4;--pink:#ec4899}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.6;font-size:14px}
.container{max-width:1440px;margin:0 auto;padding:32px 24px}
header{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:20px;flex-wrap:wrap;gap:16px}
h1{font-size:24px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.header-meta{text-align:right;color:var(--muted);font-size:12px}
h2{font-size:18px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
h3{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-size:11px}
/* Date filter bar */
.filter-bar{display:flex;align-items:center;gap:12px;padding:12px 20px;background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:20px;flex-wrap:wrap}
.filter-bar label{font-size:12px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.filter-bar select,.filter-bar input{background:var(--card2);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:6px;font-size:13px;font-family:inherit}
.filter-bar input[type="month"]{cursor:pointer}
.filter-bar select:focus,.filter-bar input:focus{outline:none;border-color:var(--accent)}
.filter-btn{background:var(--accent);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;transition:.2s}
.filter-btn:hover{background:var(--accent2)}
.filter-btn.reset{background:transparent;border:1px solid var(--border);color:var(--muted)}
.filter-btn.reset:hover{border-color:var(--accent);color:var(--text)}
.filter-active{display:inline-block;padding:4px 10px;background:rgba(99,102,241,.15);color:var(--accent2);border-radius:4px;font-size:11px;font-weight:600}
.filter-info{font-size:11px;color:var(--muted);margin-left:auto}
.tabs{display:flex;gap:4px;margin-bottom:28px;background:var(--card);padding:4px;border-radius:10px;border:1px solid var(--border)}
.tab{padding:10px 24px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);transition:.2s;flex:1;text-align:center}
.tab:hover{color:var(--text)}
.tab.active{background:var(--accent);color:#fff}
.panel{display:none}.panel.active{display:block}
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:28px}
.kpi{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px 20px}
.kpi-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
.kpi-value{font-size:24px;font-weight:700;letter-spacing:-.5px}
.kpi-sub{font-size:11px;color:var(--muted);margin-top:2px}
.kpi-value.green{color:var(--green)}.kpi-value.red{color:var(--red)}.kpi-value.accent{color:var(--accent2)}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:28px}
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-bottom:28px}
@media(max-width:1000px){.grid-2,.grid-3{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;margin-bottom:20px}
.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.chart-box{position:relative;height:280px}
.chart-tall{height:350px}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);color:var(--muted);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card)}
td{padding:7px 10px;border-bottom:1px solid rgba(30,41,59,.5)}
tr:hover td{background:rgba(99,102,241,.04)}
.num{text-align:right;font-variant-numeric:tabular-nums;font-family:'SF Mono','Fira Code',monospace;font-size:12px}
.green{color:var(--green)}.red{color:var(--red)}.yellow{color:var(--yellow)}.purple{color:var(--purple)}.cyan{color:var(--cyan)}.pink{color:var(--pink)}
.insight{background:linear-gradient(135deg,rgba(99,102,241,.08),rgba(6,182,212,.05));border-left:3px solid var(--accent);padding:14px 18px;border-radius:0 10px 10px 0;margin-bottom:14px;font-size:13px}
.insight strong{color:var(--accent2)}
.insight.warn{border-left-color:var(--yellow);background:linear-gradient(135deg,rgba(245,158,11,.08),rgba(245,158,11,.02))}
.insight.warn strong{color:var(--yellow)}
.insight.good{border-left-color:var(--green);background:linear-gradient(135deg,rgba(16,185,129,.08),rgba(16,185,129,.02))}
.insight.good strong{color:var(--green)}
.compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}
.compare-col{padding:24px;text-align:center}
.compare-col:first-child{border-right:1px solid var(--border)}
.compare-title{font-size:14px;font-weight:700;margin-bottom:16px}
.compare-stat{margin-bottom:12px}
.compare-stat-label{font-size:11px;color:var(--muted);text-transform:uppercase}
.compare-stat-value{font-size:22px;font-weight:700}
.section-title{font-size:16px;font-weight:600;margin:28px 0 16px;padding-top:16px;border-top:1px solid var(--border)}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase}
.badge-green{background:rgba(16,185,129,.15);color:var(--green)}
.badge-red{background:rgba(239,68,68,.15);color:var(--red)}
.badge-yellow{background:rgba(245,158,11,.15);color:var(--yellow)}
.scrollable{max-height:400px;overflow-y:auto}
.highlight-row{background:rgba(99,102,241,.06)!important}
.footer{text-align:center;color:var(--muted);font-size:11px;padding:32px 0 16px;border-top:1px solid var(--border);margin-top:40px}
.loading{text-align:center;padding:80px;color:var(--muted);font-size:16px}
.loading .spinner{display:inline-block;width:24px;height:24px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin-right:12px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.presets{display:flex;gap:6px;flex-wrap:wrap}
.preset-btn{background:var(--card2);border:1px solid var(--border);color:var(--muted);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;transition:.2s}
.preset-btn:hover,.preset-btn.active{border-color:var(--accent);color:var(--accent2);background:rgba(99,102,241,.1)}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>JV Vitamins — Ecommerce Performance Report</h1>
<div style="color:var(--muted);font-size:13px;margin-top:4px">Dynamic analysis with date filtering — data loaded from CSV export</div>
</div>
<div class="header-meta">
<div id="dateRange"></div>
<div id="orderCount"></div>
<div id="reportDate"></div>
</div>
</header>
<!-- Date Filter Bar -->
<div class="filter-bar">
<label>📅 Date Range:</label>
<input type="month" id="startDate">
<span style="color:var(--muted)">to</span>
<input type="month" id="endDate">
<button class="filter-btn" onclick="applyFilter()">Apply Filter</button>
<button class="filter-btn reset" onclick="resetFilter()">Reset</button>
<div class="presets">
<button class="preset-btn" onclick="setPreset('last12')">Last 12 Months</button>
<button class="preset-btn" onclick="setPreset('last24')">Last 24 Months</button>
<button class="preset-btn" onclick="setPreset('ytd')">YTD</button>
<button class="preset-btn" onclick="setPreset('2024')">2024</button>
<button class="preset-btn" onclick="setPreset('2023')">2023</button>
<button class="preset-btn" onclick="setPreset('2020')">2020</button>
<button class="preset-btn" onclick="setPreset('all')" id="presetAll">All Time</button>
</div>
<div class="filter-info" id="filterInfo"></div>
<div id="dbBadge" style="margin-left:8px"></div>
</div>
<div id="loadingMsg" class="loading"><span class="spinner"></span>Loading data…</div>
<div id="mainContent" style="display:none">
<div class="tabs">
<div class="tab active" onclick="showPanel('exec',this)">Executive Summary</div>
<div class="tab" onclick="showPanel('growth',this)">Growth & Channels</div>
<div class="tab" onclick="showPanel('products',this)">Products</div>
<div class="tab" onclick="showPanel('shipping',this)">Free Shipping Analysis</div>
<div class="tab" onclick="showPanel('customers',this)">Customers</div>
</div>
<!-- EXECUTIVE -->
<div id="exec" class="panel active">
<div class="kpi-grid" id="kpiGrid"></div>
<div id="execInsights"></div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Revenue & Orders — Monthly</h3></div><div class="chart-box chart-tall"><canvas id="revOrdersChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>AOV Trend</h3></div><div class="chart-box chart-tall"><canvas id="aovTrendChart"></canvas></div></div>
</div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>New vs Returning Customer Revenue</h3></div><div class="chart-box"><canvas id="newRetChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Yearly Performance</h3></div><div class="chart-box"><canvas id="yearlyChart"></canvas></div></div>
</div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Orders by Day of Week</h3></div><div class="chart-box"><canvas id="dowChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Payment Processor Split</h3></div><div class="chart-box"><canvas id="payChart"></canvas></div></div>
</div>
</div>
<!-- GROWTH -->
<div id="growth" class="panel">
<div class="card"><div class="card-header"><h3>Channel Performance</h3></div><div class="scrollable"><table id="channelTable"></table></div></div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Channel Revenue Trend</h3></div><div class="chart-box chart-tall"><canvas id="channelTrendChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Channel Mix Over Time</h3></div><div class="chart-box chart-tall"><canvas id="channelMixChart"></canvas></div></div>
</div>
<div class="section-title">Discount Analysis</div>
<div class="card"><table id="discountCompare"></table></div>
<div class="card"><div class="card-header"><h3>Top 20 Discount Codes</h3></div><div class="scrollable"><table id="discountTable"></table></div></div>
</div>
<!-- PRODUCTS -->
<div id="products" class="panel">
<div class="card"><div class="card-header"><h3>Product Categories</h3></div><div class="scrollable"><table id="catTable"></table></div></div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Revenue by Category</h3></div><div class="chart-box chart-tall"><canvas id="catChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Margin % by Category</h3></div><div class="chart-box chart-tall"><canvas id="catMarginChart"></canvas></div></div>
</div>
<div class="card"><div class="card-header"><h3>Top 50 Products</h3></div><div class="scrollable" style="max-height:600px"><table id="prodTable"></table></div></div>
<div class="card"><div class="card-header"><h3>Frequently Bought Together</h3></div><table id="crossSellTable"></table></div>
</div>
<!-- SHIPPING -->
<div id="shipping" class="panel">
<h2 style="margin-bottom:20px">Free Shipping Hypothesis Testing</h2>
<div id="shipInsights"></div>
<div class="kpi-grid" id="shipKpis"></div>
<div class="card">
<div class="card-header"><h3>Head-to-Head: Free Shipping vs Paid Shipping Orders</h3></div>
<div class="compare-grid" id="shipCompare"></div>
</div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Order Value Distribution</h3></div><div class="chart-box chart-tall"><canvas id="aovDistChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Shipping Cost & Free Ship % by Order Value</h3></div><div class="chart-box chart-tall"><canvas id="shipByAovChart"></canvas></div></div>
</div>
<div class="card">
<div class="card-header"><h3>Free Shipping Threshold Scenarios</h3></div>
<p style="color:var(--muted);font-size:12px;margin-bottom:14px">What happens if you offer free shipping above a minimum order value?</p>
<table id="thresholdTable"></table>
</div>
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Free Shipping % Over Time</h3></div><div class="chart-box"><canvas id="shipTrendChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Free Shipping % by Channel</h3></div><div class="chart-box"><canvas id="shipChannelChart"></canvas></div></div>
</div>
<div class="card"><div class="card-header"><h3>Delivery Methods Breakdown</h3></div><div class="scrollable"><table id="deliveryTable"></table></div></div>
</div>
<!-- CUSTOMERS -->
<div id="customers" class="panel">
<div class="grid-2">
<div class="card"><div class="card-header"><h3>Customer Segments</h3></div><div class="chart-box"><canvas id="custSegChart"></canvas></div></div>
<div class="card"><div class="card-header"><h3>Top Countries</h3></div><div class="chart-box"><canvas id="countryChart"></canvas></div></div>
</div>
<div class="card">
<div class="card-header"><h3>Cohort Retention (Monthly)</h3></div>
<div class="scrollable" style="max-height:500px"><table id="cohortTable"></table></div>
</div>
</div>
<div class="footer" id="footer"></div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// GLOBALS
// ═══════════════════════════════════════════════════════════
let RAW = null; // full dataset from JSON
let FSTART = null; // filter start YYYY-MM
let FEND = null; // filter end YYYY-MM
const COLORS = ['#6366f1','#10b981','#f59e0b','#ef4444','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#14b8a6'];
const charts = {}; // chart instances for cleanup
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
function fmt(n,d=0){if(n==null||isNaN(n))return'-';return new Intl.NumberFormat('en-GB',{minimumFractionDigits:d,maximumFractionDigits:d}).format(n)}
function gbp(n){if(n==null||isNaN(n))return'-';return '\u00a3'+fmt(n,2)}
function pct(n){return n==null?'-':fmt(n,1)+'%'}
function mc(n){return n>=60?'green':n>=40?'yellow':'red'}
function showPanel(id,el){
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById(id).classList.add('active');
el.classList.add('active');
}
function destroyChart(id){
if(charts[id]){charts[id].destroy();delete charts[id];}
}
const cDef={responsive:true,maintainAspectRatio:false,animation:{duration:300},plugins:{legend:{labels:{color:'#64748b',font:{size:11}}}},scales:{x:{grid:{color:'rgba(30,41,59,.5)'},ticks:{color:'#64748b',font:{size:10},maxRotation:45}},y:{grid:{color:'rgba(30,41,59,.5)'},ticks:{color:'#64748b',font:{size:10}}}}};
// ═══════════════════════════════════════════════════════════
// DATA FILTERING
// ═══════════════════════════════════════════════════════════
function inRange(ym){
if(!FSTART && !FEND) return true;
if(FSTART && ym < FSTART) return false;
if(FEND && ym > FEND) return false;
return true;
}
function filterArr(arr, ymKey='YearMonth'){
return arr.filter(r => inRange(r[ymKey]));
}
// Weighted average helper
function wavg(arr, valKey, weightKey){
let sumW = 0, sumVW = 0;
arr.forEach(r => {
const w = r[weightKey] || 0;
const v = r[valKey];
if(v != null && !isNaN(v)){
sumW += w;
sumVW += v * w;
}
});
return sumW > 0 ? sumVW / sumW : 0;
}
// ═══════════════════════════════════════════════════════════
// DATE FILTER UI
// ═══════════════════════════════════════════════════════════
function applyFilter(){
const s = document.getElementById('startDate').value;
const e = document.getElementById('endDate').value;
FSTART = s || null;
FEND = e || null;
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
// Re-fetch from PostgreSQL with server-side filtering
loadData(FSTART, FEND);
}
function resetFilter(){
FSTART = null; FEND = null;
document.getElementById('startDate').value = '';
document.getElementById('endDate').value = '';
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('presetAll').classList.add('active');
// Re-fetch full dataset from PostgreSQL
loadData();
}
function setPreset(key){
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
event.target.classList.add('active');
const now = new Date();
const cy = now.getFullYear();
const cm = String(now.getMonth()+1).padStart(2,'0');
switch(key){
case 'last12': {
const d = new Date(now); d.setMonth(d.getMonth()-12);
FSTART = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
FEND = null; break;
}
case 'last24': {
const d = new Date(now); d.setMonth(d.getMonth()-24);
FSTART = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
FEND = null; break;
}
case 'ytd': FSTART = cy+'-01'; FEND = null; break;
case '2024': FSTART = '2024-01'; FEND = '2024-12'; break;
case '2023': FSTART = '2023-01'; FEND = '2023-12'; break;
case '2020': FSTART = '2020-01'; FEND = '2020-12'; break;
case 'all': FSTART = null; FEND = null; break;
}
document.getElementById('startDate').value = FSTART || '';
document.getElementById('endDate').value = FEND || '';
// Re-fetch from PostgreSQL with server-side filtering
loadData(FSTART, FEND);
}
// ═══════════════════════════════════════════════════════════
// BUILD ALL SECTIONS
// ═══════════════════════════════════════════════════════════
function buildAll(){
const fm = filterArr(RAW.monthly);
updateFilterInfo(fm);
buildExec(fm);
buildGrowth(fm);
buildProducts();
buildShipping(fm);
buildCustomers(fm);
}
function updateFilterInfo(fm){
const totalOrders = fm.reduce((s,r)=>s+r.orders, 0);
const rangeText = FSTART || FEND
? `Filtered: ${FSTART||'start'}${FEND||'latest'}`
: `All Time: ${RAW.meta.dateMin} to ${RAW.meta.dateMax}`;
document.getElementById('dateRange').textContent = rangeText;
document.getElementById('orderCount').textContent = `${fmt(totalOrders)} valid orders in range`;
document.getElementById('reportDate').textContent = `Data generated ${RAW.meta.generatedAt?.split('T')[0] || ''}`;
document.getElementById('filterInfo').innerHTML = FSTART || FEND
? `<span class="filter-active">🔍 Filter Active: ${fm.length} months</span>`
: `${fm.length} months loaded`;
document.getElementById('footer').innerHTML = `
JV Vitamins Ecommerce Analysis &middot; Data: ${fmt(RAW.meta.totalRawOrders)} orders (${fmt(RAW.meta.totalValidOrders)} valid) &middot; ${RAW.meta.dateMin}${RAW.meta.dateMax}<br>
<span style="color:var(--yellow)">Note: Order line data coverage ${RAW.meta.lineDataCoverage}%. Orderlines capped at Excel 1M row limit.</span>
`;
}
// ═══════════════════════════════════════════════════════════
// EXECUTIVE SUMMARY
// ═══════════════════════════════════════════════════════════
function buildExec(fm){
const totalRev = fm.reduce((s,r) => s + r.revenue, 0);
const totalOrders = fm.reduce((s,r) => s + r.orders, 0);
const avgAOV = totalOrders > 0 ? totalRev / totalOrders : 0;
const totalCustomers = RAW.meta.uniqueCustomers; // approximate for filtered
const totalShipping = fm.reduce((s,r) => s + (r.totalShipping||0), 0);
const totalNewCust = fm.reduce((s,r) => s + r.newCustomers, 0);
const avgMargin = wavg(fm, 'avgMargin', 'orders');
const freeShipPct = fm.reduce((s,r) => s + (r.freeShipOrders||0), 0) / (totalOrders||1) * 100;
const discountPct = fm.reduce((s,r) => s + (r.discountOrders||0), 0) / (totalOrders||1) * 100;
const avgItems = fm.reduce((s,r) => s + (r.totalItems||0), 0) / (totalOrders||1);
// New vs returning revenue from newReturningMonthly
const nrFiltered = filterArr(RAW.newReturningMonthly);
const newRev = nrFiltered.filter(r=>r.IsNewCustomer===1).reduce((s,r)=>s+r.revenue,0);
const retRev = nrFiltered.filter(r=>r.IsNewCustomer===0).reduce((s,r)=>s+r.revenue,0);
const retOrders = nrFiltered.filter(r=>r.IsNewCustomer===0).reduce((s,r)=>s+r.orders,0);
const repeatRevPct = totalRev > 0 ? retRev / totalRev * 100 : 0;
const kpis = [
{l:'Gross Revenue', v:gbp(totalRev), s:FSTART||FEND?'Filtered period':'All time', c:''},
{l:'Valid Orders', v:fmt(totalOrders), s:`${fm.length} months`, c:''},
{l:'AOV (Mean)', v:gbp(avgAOV), s:`${fmt(avgItems,1)} items/order`, c:'accent'},
{l:'Avg Gross Margin', v:pct(avgMargin), s:`${RAW.meta.lineDataCoverage}% data coverage`, c:'green'},
{l:'Shipping Revenue', v:gbp(totalShipping), s:`${pct(freeShipPct)} orders ship free`, c:''},
{l:'Discounted Orders', v:pct(discountPct), s:`of orders used a code`, c:'yellow'},
{l:'Returning Revenue', v:gbp(retRev), s:`${pct(repeatRevPct)} of total`, c:'green'},
{l:'New Customers', v:fmt(totalNewCust), s:`First orders in range`, c:'cyan'},
];
document.getElementById('kpiGrid').innerHTML = kpis.map(k =>
`<div class="kpi"><div class="kpi-label">${k.l}</div><div class="kpi-value ${k.c}">${k.v}</div><div class="kpi-sub">${k.s}</div></div>`
).join('');
// Dynamic range-aware insights
// Split into halves for trend comparison
const half = Math.floor(fm.length / 2);
const firstHalf = fm.slice(0, half);
const secondHalf = fm.slice(half);
const fhRev = firstHalf.reduce((s,r)=>s+r.revenue,0);
const shRev = secondHalf.reduce((s,r)=>s+r.revenue,0);
const revTrend = fhRev > 0 ? ((shRev - fhRev) / fhRev * 100) : 0;
const fhOrders = firstHalf.reduce((s,r)=>s+r.orders,0);
const shOrders = secondHalf.reduce((s,r)=>s+r.orders,0);
const orderTrend = fhOrders > 0 ? ((shOrders - fhOrders) / fhOrders * 100) : 0;
const fhNew = firstHalf.reduce((s,r)=>s+r.newCustomers,0);
const shNew = secondHalf.reduce((s,r)=>s+r.newCustomers,0);
const newTrend = fhNew > 0 ? ((shNew - fhNew) / fhNew * 100) : 0;
const fhAOV = fhOrders > 0 ? fhRev / fhOrders : 0;
const shAOV = shOrders > 0 ? shRev / shOrders : 0;
const aovTrend = fhAOV > 0 ? ((shAOV - fhAOV) / fhAOV * 100) : 0;
// Channel concentration for this date range
const chFilt = filterArr(RAW.channelMonthly);
const chTotals = {};
chFilt.forEach(r => { chTotals[r.ReferrerSource] = (chTotals[r.ReferrerSource]||0) + r.orders; });
const chTotal = Object.values(chTotals).reduce((a,b)=>a+b,0);
const googleOrg = (chTotals['Organic']||0) + (chTotals['Google Adwords']||0);
const googlePct = chTotal > 0 ? googleOrg / chTotal * 100 : 0;
const socialPct = chTotal > 0 ? ((chTotals['Facebook']||0) / chTotal * 100) : 0;
const rangeLabel = FSTART && FEND ? `${FSTART} to ${FEND}` : FSTART ? `${FSTART} onwards` : FEND ? `up to ${FEND}` : 'all time';
const halfLabel1 = firstHalf.length ? `${firstHalf[0].YearMonth}${firstHalf[firstHalf.length-1].YearMonth}` : '';
const halfLabel2 = secondHalf.length ? `${secondHalf[0].YearMonth}${secondHalf[secondHalf.length-1].YearMonth}` : '';
let insightsHtml = '';
// Revenue trend
if(fm.length >= 4) {
insightsHtml += `<div class="insight ${revTrend >= 0 ? 'good' : 'warn'}"><strong>Revenue ${revTrend >= 0 ? '↑' : '↓'} ${pct(Math.abs(revTrend))}:</strong> ${gbp(shRev)} in ${halfLabel2} vs ${gbp(fhRev)} in ${halfLabel1}. ${revTrend < -10 ? 'Declining trend in this range.' : revTrend > 10 ? 'Growing in this range.' : 'Relatively flat.'}</div>`;
}
// New customer trend
if(fm.length >= 4 && fhNew > 0) {
insightsHtml += `<div class="insight ${newTrend >= 0 ? 'good' : 'warn'}"><strong>New customers ${newTrend >= 0 ? '↑' : '↓'} ${pct(Math.abs(newTrend))}:</strong> ${fmt(shNew)} new customers in second half vs ${fmt(fhNew)} in first half of this range.</div>`;
}
// AOV trend
if(fm.length >= 4) {
insightsHtml += `<div class="insight ${aovTrend >= 0 ? 'good' : 'warn'}"><strong>AOV ${aovTrend >= 0 ? '↑' : '↓'} ${pct(Math.abs(aovTrend))}:</strong> ${gbp(shAOV)} vs ${gbp(fhAOV)}. ${aovTrend > 5 ? 'Customers are spending more per order.' : aovTrend < -5 ? 'Average basket value declining.' : 'AOV holding steady.'}</div>`;
}
// Margins
insightsHtml += `<div class="insight ${avgMargin >= 55 ? 'good' : 'warn'}"><strong>Avg margin ${pct(avgMargin)}:</strong> ${avgMargin >= 55 ? 'Healthy margins across the product range.' : 'Margins under pressure — review product costs and pricing.'}</div>`;
// Returning revenue
insightsHtml += `<div class="insight"><strong>Returning customer revenue:</strong> ${pct(repeatRevPct)} of revenue comes from repeat buyers (${rangeLabel}). ${repeatRevPct > 60 ? 'Extremely loyal base — but dependency on existing customers may mask acquisition weakness.' : repeatRevPct > 40 ? 'Strong loyalty base to leverage.' : 'Opportunity to improve retention.'}</div>`;
// Channel concentration
if(chTotal > 0) {
insightsHtml += `<div class="insight ${googlePct > 80 ? 'warn' : ''}"><strong>Channel concentration:</strong> ${pct(googlePct)} of orders from Google channels (Organic + Ads) in this range. ${socialPct < 1 ? 'Social commerce is under ' + pct(socialPct) + ' — virtually zero discovery from social channels.' : 'Social contributing ' + pct(socialPct) + '.'}</div>`;
}
// Items per order
insightsHtml += `<div class="insight ${avgItems < 2 ? 'warn' : 'good'}"><strong>Items per order: ${fmt(avgItems,1)}.</strong> ${avgItems < 2 ? 'Most orders are single-item. Bundling and cross-sell could lift AOV.' : 'Good cross-sell rate.'}</div>`;
document.getElementById('execInsights').innerHTML = insightsHtml;
// Revenue + Orders chart
destroyChart('revOrdersChart');
charts['revOrdersChart'] = new Chart(document.getElementById('revOrdersChart'), {
type:'line', data:{labels:fm.map(r=>r.YearMonth), datasets:[
{label:'Revenue',data:fm.map(r=>r.revenue),borderColor:'#6366f1',backgroundColor:'#6366f133',fill:true,tension:.3,pointRadius:0,yAxisID:'y'},
{label:'Orders',data:fm.map(r=>r.orders),borderColor:'#10b981',tension:.3,pointRadius:0,yAxisID:'y1'}
]}, options:{...cDef,scales:{...cDef.scales,
y:{...cDef.scales.y,position:'left',ticks:{...cDef.scales.y.ticks,callback:v=>'\u00a3'+fmt(v/1000)+'k'}},
y1:{grid:{display:false},ticks:{color:'#10b981',font:{size:10}},position:'right'}
}}
});
// AOV chart
destroyChart('aovTrendChart');
charts['aovTrendChart'] = new Chart(document.getElementById('aovTrendChart'), {
type:'line', data:{labels:fm.map(r=>r.YearMonth), datasets:[
{label:'Mean AOV',data:fm.map(r=>r.aov),borderColor:'#a855f7',tension:.3,pointRadius:0}
]}, options:{...cDef,plugins:{...cDef.plugins,legend:{display:false}},
scales:{...cDef.scales,y:{...cDef.scales.y,ticks:{...cDef.scales.y.ticks,callback:v=>'\u00a3'+v}}}}
});
// New vs Returning
destroyChart('newRetChart');
const nrByMonth = {};
nrFiltered.forEach(r => {
if(!nrByMonth[r.YearMonth]) nrByMonth[r.YearMonth] = {new:0,ret:0};
if(r.IsNewCustomer) nrByMonth[r.YearMonth].new = r.revenue;
else nrByMonth[r.YearMonth].ret = r.revenue;
});
const nrLabels = Object.keys(nrByMonth).sort();
charts['newRetChart'] = new Chart(document.getElementById('newRetChart'), {
type:'bar', data:{labels:nrLabels, datasets:[
{label:'New Customer Revenue',data:nrLabels.map(l=>nrByMonth[l].new),backgroundColor:'#06b6d4cc'},
{label:'Returning Revenue',data:nrLabels.map(l=>nrByMonth[l].ret),backgroundColor:'#6366f1cc'}
]}, options:{...cDef,scales:{...cDef.scales,x:{...cDef.scales.x,stacked:true},y:{...cDef.scales.y,stacked:true,ticks:{callback:v=>'\u00a3'+fmt(v/1000)+'k'}}}}
});
// Yearly
destroyChart('yearlyChart');
const yearAgg = {};
fm.forEach(r => {
const y = r.YearMonth.substring(0,4);
if(!yearAgg[y]) yearAgg[y] = {revenue:0, orders:0};
yearAgg[y].revenue += r.revenue;
yearAgg[y].orders += r.orders;
});
const years = Object.keys(yearAgg).sort();
charts['yearlyChart'] = new Chart(document.getElementById('yearlyChart'), {
type:'bar', data:{labels:years, datasets:[
{label:'Revenue',data:years.map(y=>yearAgg[y].revenue),backgroundColor:'#6366f1cc',yAxisID:'y'},
{label:'Orders',data:years.map(y=>yearAgg[y].orders),type:'line',borderColor:'#10b981',pointRadius:2,yAxisID:'y1'}
]}, options:{...cDef,scales:{...cDef.scales,
y:{...cDef.scales.y,ticks:{callback:v=>'\u00a3'+fmt(v/1000000,1)+'M'}},
y1:{grid:{display:false},ticks:{color:'#10b981',font:{size:10},callback:v=>fmt(v/1000)+'k'},position:'right'}
}}
});
// Day of week
destroyChart('dowChart');
const dowFiltered = filterArr(RAW.dowMonthly);
const dowAgg = {};
const dowOrder = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
dowFiltered.forEach(r => {
if(!dowAgg[r.DayOfWeek]) dowAgg[r.DayOfWeek] = {orders:0, revenue:0};
dowAgg[r.DayOfWeek].orders += r.orders;
dowAgg[r.DayOfWeek].revenue += r.revenue;
});
charts['dowChart'] = new Chart(document.getElementById('dowChart'), {
type:'bar', data:{labels:dowOrder, datasets:[{data:dowOrder.map(d=>(dowAgg[d]||{orders:0}).orders),backgroundColor:COLORS.slice(0,7).map(c=>c+'cc')}]},
options:{...cDef,plugins:{legend:{display:false}}}
});
// Payment
destroyChart('payChart');
const payFiltered = filterArr(RAW.paymentMonthly);
const payAgg = {};
payFiltered.forEach(r => {
const k = (r.PaymentProcessor||'Unknown').toUpperCase();
if(!payAgg[k]) payAgg[k] = 0;
payAgg[k] += r.orders;
});
const payEntries = Object.entries(payAgg).sort((a,b)=>b[1]-a[1]).slice(0,8);
charts['payChart'] = new Chart(document.getElementById('payChart'), {
type:'doughnut', data:{labels:payEntries.map(e=>e[0]),datasets:[{data:payEntries.map(e=>e[1]),backgroundColor:COLORS}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{color:'#64748b'}}}}
});
}
// ═══════════════════════════════════════════════════════════
// GROWTH & CHANNELS
// ═══════════════════════════════════════════════════════════
function buildGrowth(fm){
const chFiltered = filterArr(RAW.channelMonthly);
// Aggregate channels
const chAgg = {};
chFiltered.forEach(r => {
const k = r.ReferrerSource;
if(!chAgg[k]) chAgg[k] = {orders:0,revenue:0,sumAOV:0,sumItems:0,newCust:0,freeship:0,disc:0};
chAgg[k].orders += r.orders;
chAgg[k].revenue += r.revenue;
chAgg[k].sumAOV += r.avgAOV * r.orders;
chAgg[k].sumItems += r.avgItems * r.orders;
chAgg[k].newCust += r.newPct * r.orders;
chAgg[k].freeship += r.freeShipPct * r.orders;
chAgg[k].disc += r.discountPct * r.orders;
});
const channels = Object.entries(chAgg).map(([k,v])=>({
name:k, orders:v.orders, revenue:v.revenue,
avgAOV:v.orders?v.sumAOV/v.orders:0, avgItems:v.orders?v.sumItems/v.orders:0,
newPct:v.orders?v.newCust/v.orders*100:0, freeShipPct:v.orders?v.freeship/v.orders*100:0,
discountPct:v.orders?v.disc/v.orders*100:0
})).sort((a,b)=>b.revenue-a.revenue);
let h='<thead><tr><th>Channel</th><th class="num">Orders</th><th class="num">Revenue</th><th class="num">AOV</th><th class="num">Items/Order</th><th class="num">% New</th><th class="num">% Discounted</th><th class="num">% Free Ship</th></tr></thead><tbody>';
channels.forEach(c=>{
h+=`<tr><td><strong>${c.name}</strong></td><td class="num">${fmt(c.orders)}</td><td class="num">${gbp(c.revenue)}</td><td class="num">${gbp(c.avgAOV)}</td><td class="num">${fmt(c.avgItems,1)}</td><td class="num">${pct(c.newPct)}</td><td class="num">${pct(c.discountPct)}</td><td class="num">${pct(c.freeShipPct)}</td></tr>`;
});
document.getElementById('channelTable').innerHTML=h+'</tbody>';
// Channel trend (top 4)
const top4 = channels.slice(0,4).map(c=>c.name);
const cmByChannel = {};
top4.forEach(ch=>{cmByChannel[ch]={}});
chFiltered.forEach(r=>{if(cmByChannel[r.ReferrerSource]) cmByChannel[r.ReferrerSource][r.YearMonth]=r.revenue});
const cmLabels = [...new Set(chFiltered.map(r=>r.YearMonth))].sort();
destroyChart('channelTrendChart');
charts['channelTrendChart'] = new Chart(document.getElementById('channelTrendChart'),{
type:'line',data:{labels:cmLabels,datasets:top4.map((ch,i)=>({
label:ch,data:cmLabels.map(l=>cmByChannel[ch][l]||0),borderColor:COLORS[i],tension:.3,pointRadius:0
}))},options:{...cDef,scales:{...cDef.scales,y:{...cDef.scales.y,ticks:{callback:v=>'\u00a3'+fmt(v/1000)+'k'}}}}
});
destroyChart('channelMixChart');
charts['channelMixChart'] = new Chart(document.getElementById('channelMixChart'),{
type:'bar',data:{labels:cmLabels,datasets:top4.map((ch,i)=>({
label:ch,data:cmLabels.map(l=>cmByChannel[ch][l]||0),backgroundColor:COLORS[i]+'aa'
}))},options:{...cDef,scales:{...cDef.scales,x:{...cDef.scales.x,stacked:true},y:{...cDef.scales.y,stacked:true,ticks:{callback:v=>'\u00a3'+fmt(v/1000)+'k'}}}}
});
// Discount compare
const discFiltered = filterArr(RAW.discountMonthly);
const discAgg = {0:{count:0,sumAOV:0,sumItems:0,sumMargin:0,sumShip:0,rev:0},1:{count:0,sumAOV:0,sumItems:0,sumMargin:0,sumShip:0,rev:0}};
discFiltered.forEach(r=>{
const k = r.HasDiscount;
discAgg[k].count += r.count;
discAgg[k].sumAOV += r.avgAOV * r.count;
discAgg[k].sumItems += r.avgItems * r.count;
discAgg[k].sumMargin += r.avgMargin * r.count;
discAgg[k].sumShip += r.avgShipping * r.count;
discAgg[k].rev += r.revenue;
});
let dh='<thead><tr><th>Segment</th><th class="num">Orders</th><th class="num">Avg AOV</th><th class="num">Avg Items</th><th class="num">Avg Margin %</th><th class="num">Avg Shipping</th></tr></thead><tbody>';
[0,1].forEach(k=>{
const d = discAgg[k];
const n = d.count||1;
dh+=`<tr><td>${k?'With Discount Code':'No Discount'}</td><td class="num">${fmt(d.count)}</td><td class="num">${gbp(d.sumAOV/n)}</td><td class="num">${fmt(d.sumItems/n,1)}</td><td class="num ${mc(d.sumMargin/n)}">${pct(d.sumMargin/n)}</td><td class="num">${gbp(d.sumShip/n)}</td></tr>`;
});
document.getElementById('discountCompare').innerHTML=dh+'</tbody>';
// Top discount codes
const dcFiltered = filterArr(RAW.discountCodesMonthly);
const dcAgg = {};
dcFiltered.forEach(r=>{
if(!dcAgg[r.DiscountCode]) dcAgg[r.DiscountCode]={uses:0,revenue:0,sumAOV:0,sumDiscPct:0};
dcAgg[r.DiscountCode].uses += r.uses;
dcAgg[r.DiscountCode].revenue += r.revenue;
dcAgg[r.DiscountCode].sumAOV += r.avgAOV * r.uses;
dcAgg[r.DiscountCode].sumDiscPct += (r.avgDiscountPct||0) * r.uses;
});
const dcSorted = Object.entries(dcAgg).map(([k,v])=>({code:k,...v,avgAOV:v.sumAOV/(v.uses||1),avgDiscPct:v.sumDiscPct/(v.uses||1)})).sort((a,b)=>b.uses-a.uses).slice(0,20);
let ch2='<thead><tr><th>Code</th><th class="num">Uses</th><th class="num">Revenue</th><th class="num">AOV</th><th class="num">Avg Disc %</th></tr></thead><tbody>';
dcSorted.forEach(c=>{
ch2+=`<tr><td><code>${c.code}</code></td><td class="num">${fmt(c.uses)}</td><td class="num">${gbp(c.revenue)}</td><td class="num">${gbp(c.avgAOV)}</td><td class="num">${pct(c.avgDiscPct)}</td></tr>`;
});
document.getElementById('discountTable').innerHTML=ch2+'</tbody>';
}
// ═══════════════════════════════════════════════════════════
// PRODUCTS
// ═══════════════════════════════════════════════════════════
function buildProducts(){
// Categories
const catFiltered = filterArr(RAW.categoryMonthly);
const catAgg = {};
catFiltered.forEach(r=>{
if(!catAgg[r.Category]) catAgg[r.Category]={totalRevenue:0,totalRevenueExVAT:0,totalCost:0,totalQty:0,orderCount:0,uniqueProducts:0};
catAgg[r.Category].totalRevenue += r.totalRevenue;
catAgg[r.Category].totalRevenueExVAT += r.totalRevenueExVAT;
catAgg[r.Category].totalCost += r.totalCost;
catAgg[r.Category].totalQty += r.totalQty;
catAgg[r.Category].orderCount += r.orderCount;
catAgg[r.Category].uniqueProducts = Math.max(catAgg[r.Category].uniqueProducts, r.uniqueProducts||0);
});
const cats = Object.entries(catAgg).map(([k,v])=>{
const margin = v.totalRevenueExVAT - v.totalCost;
const marginPct = v.totalRevenueExVAT > 0 ? margin / v.totalRevenueExVAT * 100 : 0;
return {name:k,...v,margin,marginPct};
}).sort((a,b)=>b.totalRevenue-a.totalRevenue);
let h='<thead><tr><th>Category</th><th class="num">Revenue</th><th class="num">Units</th><th class="num">Orders</th><th class="num">COGS</th><th class="num">Margin</th><th class="num">Margin %</th></tr></thead><tbody>';
cats.forEach(c=>{
h+=`<tr><td><strong>${c.name}</strong></td><td class="num">${gbp(c.totalRevenue)}</td><td class="num">${fmt(c.totalQty)}</td><td class="num">${fmt(c.orderCount)}</td><td class="num">${gbp(c.totalCost)}</td><td class="num">${gbp(c.margin)}</td><td class="num ${mc(c.marginPct)}">${pct(c.marginPct)}</td></tr>`;
});
document.getElementById('catTable').innerHTML=h+'</tbody>';
// Category charts
const top10 = cats.slice(0,10);
destroyChart('catChart');
charts['catChart'] = new Chart(document.getElementById('catChart'),{
type:'bar',data:{labels:top10.map(c=>c.name),datasets:[{data:top10.map(c=>c.totalRevenue),backgroundColor:COLORS.map(c=>c+'cc')}]},
options:{...cDef,indexAxis:'y',plugins:{legend:{display:false}},scales:{...cDef.scales,x:{...cDef.scales.x,ticks:{callback:v=>'\u00a3'+fmt(v/1000)+'k'}}}}
});
destroyChart('catMarginChart');
charts['catMarginChart'] = new Chart(document.getElementById('catMarginChart'),{
type:'bar',data:{labels:top10.map(c=>c.name),datasets:[{data:top10.map(c=>c.marginPct),backgroundColor:top10.map(c=>c.marginPct>=60?'#10b981cc':c.marginPct>=40?'#f59e0bcc':'#ef4444cc')}]},
options:{...cDef,indexAxis:'y',plugins:{legend:{display:false}},scales:{...cDef.scales,x:{...cDef.scales.x,max:100,ticks:{callback:v=>v+'%'}}}}
});
// Products
const prodFiltered = filterArr(RAW.productMonthly);
const prodAgg = {};
prodFiltered.forEach(r=>{
const k = r.Description+'|||'+r.SKUDescription;
if(!prodAgg[k]) prodAgg[k]={desc:r.Description,sku:r.SKUDescription,cat:r.Category,totalRevenue:0,totalRevenueExVAT:0,totalCost:0,totalQty:0,orderCount:0};
prodAgg[k].totalRevenue += r.totalRevenue;
prodAgg[k].totalRevenueExVAT += r.totalRevenueExVAT;
prodAgg[k].totalCost += r.totalCost;
prodAgg[k].totalQty += r.totalQty;
prodAgg[k].orderCount += r.orderCount;
});
const prods = Object.values(prodAgg).map(v=>{
const margin = v.totalRevenueExVAT - v.totalCost;
const marginPct = v.totalRevenueExVAT > 0 ? margin / v.totalRevenueExVAT * 100 : 0;
return {...v,margin,marginPct};
}).sort((a,b)=>b.totalRevenue-a.totalRevenue).slice(0,50);
let ph='<thead><tr><th>#</th><th>Product</th><th>Variant</th><th>Category</th><th class="num">Revenue</th><th class="num">Units</th><th class="num">Orders</th><th class="num">COGS</th><th class="num">Margin %</th></tr></thead><tbody>';
prods.forEach((p,i)=>{
ph+=`<tr><td>${i+1}</td><td>${p.desc}</td><td>${p.sku}</td><td><span class="badge badge-green">${p.cat}</span></td><td class="num">${gbp(p.totalRevenue)}</td><td class="num">${fmt(p.totalQty)}</td><td class="num">${fmt(p.orderCount)}</td><td class="num">${gbp(p.totalCost)}</td><td class="num ${mc(p.marginPct)}">${pct(p.marginPct)}</td></tr>`;
});
document.getElementById('prodTable').innerHTML=ph+'</tbody>';
// Cross-sell (static — not filterable by date)
let cs='<thead><tr><th>#</th><th>Product A</th><th>Product B</th><th class="num">Times Bought Together</th></tr></thead><tbody>';
(RAW.crossSell||[]).forEach((p,i)=>{
cs+=`<tr><td>${i+1}</td><td>${p.product1}</td><td>${p.product2}</td><td class="num">${fmt(p.count)}</td></tr>`;
});
document.getElementById('crossSellTable').innerHTML=cs+'</tbody>';
}
// ═══════════════════════════════════════════════════════════
// SHIPPING
// ═══════════════════════════════════════════════════════════
function buildShipping(fm){
// Shipping comparison
const scFiltered = filterArr(RAW.shippingCompMonthly);
const scAgg = {0:{count:0,sumAOV:0,sumItems:0,sumMargin:0,sumNew:0,sumDisc:0,sumShip:0},
1:{count:0,sumAOV:0,sumItems:0,sumMargin:0,sumNew:0,sumDisc:0,sumShip:0}};
scFiltered.forEach(r=>{
const k = r.IsFreeShipping;
scAgg[k].count += r.count;
scAgg[k].sumAOV += r.avgAOV * r.count;
scAgg[k].sumItems += r.avgItems * r.count;
scAgg[k].sumMargin += r.avgMargin * r.count;
scAgg[k].sumNew += r.newPct * r.count;
scAgg[k].sumDisc += r.discountPct * r.count;
scAgg[k].sumShip += r.avgShipping * r.count;
});
const free = scAgg[1], paid = scAgg[0];
const fn = free.count||1, pn = paid.count||1;
const totalShipRev = fm.reduce((s,r)=>s+(r.totalShipping||0),0);
const totalOrders = fm.reduce((s,r)=>s+r.orders,0);
const freeShipPct = free.count / (totalOrders||1) * 100;
// KPIs
const shipKpis = [
{l:'Total Shipping Revenue',v:gbp(totalShipRev),s:`${fmt(paid.count)} paid orders`,c:''},
{l:'Free Shipping Rate',v:pct(freeShipPct),s:`${fmt(free.count)} free orders`,c:'green'},
{l:'Avg Shipping (Paid)',v:gbp(paid.sumShip/pn),s:'Per paid-shipping order',c:'yellow'},
{l:'Avg Shipping (All)',v:gbp(totalShipRev/(totalOrders||1)),s:'Across all orders',c:''},
];
document.getElementById('shipKpis').innerHTML = shipKpis.map(k =>
`<div class="kpi"><div class="kpi-label">${k.l}</div><div class="kpi-value ${k.c}">${k.v}</div><div class="kpi-sub">${k.s}</div></div>`
).join('');
// Insights
document.getElementById('shipInsights').innerHTML = `
<div class="insight"><strong>Current state:</strong> ${pct(freeShipPct)} of orders ship free. Total shipping revenue: ${gbp(totalShipRev)}.</div>
<div class="insight ${free.sumAOV/fn < paid.sumAOV/pn ? 'warn' : ''}"><strong>AOV comparison:</strong> Free-shipping orders avg ${gbp(free.sumAOV/fn)} vs paid shipping ${gbp(paid.sumAOV/pn)}. ${free.sumAOV/fn < paid.sumAOV/pn ? 'Free shipping orders have lower AOV.' : 'Free shipping orders have higher AOV.'}</div>
<div class="insight good"><strong>Margin comparison:</strong> Free-shipping margin ${pct(free.sumMargin/fn)} vs paid ${pct(paid.sumMargin/pn)}.</div>
`;
// Comparison
document.getElementById('shipCompare').innerHTML = `
<div class="compare-col">
<div class="compare-title green">🆓 Free Shipping</div>
<div class="compare-stat"><div class="compare-stat-label">Orders</div><div class="compare-stat-value">${fmt(free.count)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">AOV (Mean)</div><div class="compare-stat-value">${gbp(free.sumAOV/fn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">Items / Order</div><div class="compare-stat-value">${fmt(free.sumItems/fn,1)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">Gross Margin</div><div class="compare-stat-value green">${pct(free.sumMargin/fn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">% New Customers</div><div class="compare-stat-value">${pct(free.sumNew/fn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">% Discounted</div><div class="compare-stat-value">${pct(free.sumDisc/fn)}</div></div>
</div>
<div class="compare-col">
<div class="compare-title yellow">📦 Paid Shipping</div>
<div class="compare-stat"><div class="compare-stat-label">Orders</div><div class="compare-stat-value">${fmt(paid.count)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">AOV (Mean)</div><div class="compare-stat-value">${gbp(paid.sumAOV/pn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">Items / Order</div><div class="compare-stat-value">${fmt(paid.sumItems/pn,1)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">Gross Margin</div><div class="compare-stat-value yellow">${pct(paid.sumMargin/pn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">% New Customers</div><div class="compare-stat-value">${pct(paid.sumNew/pn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">% Discounted</div><div class="compare-stat-value">${pct(paid.sumDisc/pn)}</div></div>
<div class="compare-stat"><div class="compare-stat-label">Avg Shipping Paid</div><div class="compare-stat-value red">${gbp(paid.sumShip/pn)}</div></div>
</div>
`;
// AOV Distribution
const aovFiltered = filterArr(RAW.aovMonthly);
const aovAgg = {};
aovFiltered.forEach(r=>{
if(!aovAgg[r.AOVBucket]) aovAgg[r.AOVBucket]={count:0,revenue:0,sumShip:0,sumFree:0};
aovAgg[r.AOVBucket].count += r.count;
aovAgg[r.AOVBucket].revenue += r.revenue;
aovAgg[r.AOVBucket].sumShip += r.avgShipping * r.count;
aovAgg[r.AOVBucket].sumFree += r.pctFreeShip * r.count;
});
const bucketOrder = ['<5','5-10','10-15','15-20','20-25','25-30','30-35','35-40','40-50','50-60','60-75','75-100','100-150','150-200','200-500','500+'];
const aovBuckets = bucketOrder.filter(b=>aovAgg[b]&&aovAgg[b].count>0).map(b=>({
bucket:b,...aovAgg[b],
avgShipping:aovAgg[b].sumShip/(aovAgg[b].count||1),
pctFreeShip:aovAgg[b].sumFree/(aovAgg[b].count||1)
}));
destroyChart('aovDistChart');
charts['aovDistChart'] = new Chart(document.getElementById('aovDistChart'),{
type:'bar',data:{labels:aovBuckets.map(a=>'£'+a.bucket),datasets:[{label:'Orders',data:aovBuckets.map(a=>a.count),backgroundColor:aovBuckets.map(a=>{const v=parseFloat(a.bucket);return v>=25&&v<40?'#6366f1cc':'#334155cc'})}]},
options:{...cDef,plugins:{legend:{display:false}}}
});
const aovSig = aovBuckets.filter(a=>a.count>100);
destroyChart('shipByAovChart');
charts['shipByAovChart'] = new Chart(document.getElementById('shipByAovChart'),{
type:'bar',data:{labels:aovSig.map(a=>'£'+a.bucket),datasets:[
{label:'Avg Shipping £',data:aovSig.map(a=>a.avgShipping),backgroundColor:'#ef4444aa',yAxisID:'y'},
{label:'% Free Shipping',data:aovSig.map(a=>a.pctFreeShip*100),backgroundColor:'#10b981aa',yAxisID:'y1'}
]},options:{...cDef,scales:{...cDef.scales,y:{...cDef.scales.y,position:'left',ticks:{callback:v=>'£'+v}},y1:{grid:{display:false},ticks:{color:'#10b981',callback:v=>v+'%'},position:'right',max:100}}}
});
// Threshold analysis
const thFiltered = filterArr(RAW.thresholdMonthly);
const thAgg = {totalOrders:0, totalShipRev:0};
[15,20,25,30,35,40,50].forEach(t=>{thAgg['t'+t]={eligible:0,shipAbsorbed:0,near:0,sumNearGap:0}});
thFiltered.forEach(r=>{
thAgg.totalOrders += r.totalOrders;
thAgg.totalShipRev += r.totalShippingRev;
[15,20,25,30,35,40,50].forEach(t=>{
thAgg['t'+t].eligible += r['t'+t+'_eligible']||0;
thAgg['t'+t].shipAbsorbed += r['t'+t+'_shipAbsorbed']||0;
thAgg['t'+t].near += r['t'+t+'_near']||0;
thAgg['t'+t].sumNearGap += (r['t'+t+'_nearGap']||0) * (r['t'+t+'_near']||0);
});
});
let th='<thead><tr><th>Scenario</th><th class="num">Orders Qualifying</th><th class="num">% of Total</th><th class="num">Shipping Cost Absorbed</th><th class="num">Remaining Ship Rev</th><th class="num">Near-Threshold Orders</th><th class="num">Avg Gap to Qualify</th></tr></thead><tbody>';
th+=`<tr><td><strong>🟢 Current</strong></td><td class="num">${fmt(free.count)}</td><td class="num">${pct(freeShipPct)}</td><td class="num">-</td><td class="num">${gbp(thAgg.totalShipRev)}</td><td class="num">-</td><td class="num">-</td></tr>`;
[15,20,25,30,35,40,50].forEach(t=>{
const d = thAgg['t'+t];
const hl = [25,30,35].includes(t) ? 'class="highlight-row"' : '';
th+=`<tr ${hl}><td><strong>🟡 Free ship ≥£${t}</strong></td><td class="num">${fmt(d.eligible)}</td><td class="num">${pct(d.eligible/(thAgg.totalOrders||1)*100)}</td><td class="num red">${gbp(d.shipAbsorbed)}</td><td class="num">${gbp(thAgg.totalShipRev - d.shipAbsorbed)}</td><td class="num cyan">${fmt(d.near)}</td><td class="num">${d.near>0?gbp(d.sumNearGap/d.near):'-'}</td></tr>`;
});
document.getElementById('thresholdTable').innerHTML=th+'</tbody>';
// Shipping trend
destroyChart('shipTrendChart');
charts['shipTrendChart'] = new Chart(document.getElementById('shipTrendChart'),{
type:'line',data:{labels:fm.map(r=>r.YearMonth),datasets:[
{label:'% Free Shipping',data:fm.map(r=>r.freeShipPct*100),borderColor:'#10b981',tension:.3,pointRadius:0},
{label:'Avg Shipping/Order',data:fm.map(r=>r.avgShipping),borderColor:'#ef4444',tension:.3,pointRadius:0,yAxisID:'y1'}
]},options:{...cDef,scales:{...cDef.scales,y:{...cDef.scales.y,ticks:{callback:v=>v+'%'}},y1:{grid:{display:false},ticks:{color:'#ef4444',callback:v=>'£'+v},position:'right'}}}
});
// Shipping by channel
destroyChart('shipChannelChart');
const chFiltered2 = filterArr(RAW.channelMonthly);
const chShipAgg = {};
chFiltered2.forEach(r=>{
if(!chShipAgg[r.ReferrerSource]) chShipAgg[r.ReferrerSource]={orders:0,freeOrders:0};
chShipAgg[r.ReferrerSource].orders += r.orders;
chShipAgg[r.ReferrerSource].freeOrders += r.freeShipPct * r.orders;
});
const chShip = Object.entries(chShipAgg).filter(([k,v])=>v.orders>500).map(([k,v])=>({name:k,pct:v.freeOrders/v.orders*100})).sort((a,b)=>b.pct-a.pct);
charts['shipChannelChart'] = new Chart(document.getElementById('shipChannelChart'),{
type:'bar',data:{labels:chShip.map(c=>c.name),datasets:[{label:'% Free Shipping',data:chShip.map(c=>c.pct),backgroundColor:COLORS.map(c=>c+'cc')}]},
options:{...cDef,plugins:{legend:{display:false}},scales:{...cDef.scales,y:{...cDef.scales.y,max:100,ticks:{callback:v=>v+'%'}}}}
});
// Delivery methods
const delFiltered = filterArr(RAW.deliveryMonthly);
const delAgg = {};
delFiltered.forEach(r=>{
if(!delAgg[r.DeliveryMethod]) delAgg[r.DeliveryMethod]={orders:0,revenue:0,sumShip:0,sumAOV:0};
delAgg[r.DeliveryMethod].orders += r.orders;
delAgg[r.DeliveryMethod].revenue += r.revenue;
delAgg[r.DeliveryMethod].sumShip += r.avgShipping * r.orders;
delAgg[r.DeliveryMethod].sumAOV += r.avgAOV * r.orders;
});
const dels = Object.entries(delAgg).map(([k,v])=>({name:k,...v,avgShipping:v.sumShip/(v.orders||1),avgAOV:v.sumAOV/(v.orders||1)})).sort((a,b)=>b.orders-a.orders);
let dh='<thead><tr><th>Delivery Method</th><th class="num">Orders</th><th class="num">Revenue</th><th class="num">Avg Shipping</th><th class="num">Avg AOV</th></tr></thead><tbody>';
dels.forEach(d=>{
const isFree = d.name.toLowerCase().includes('free');
dh+=`<tr><td>${isFree?'🆓 ':'📦 '}${d.name}</td><td class="num">${fmt(d.orders)}</td><td class="num">${gbp(d.revenue)}</td><td class="num">${gbp(d.avgShipping)}</td><td class="num">${gbp(d.avgAOV)}</td></tr>`;
});
document.getElementById('deliveryTable').innerHTML=dh+'</tbody>';
}
// ═══════════════════════════════════════════════════════════
// CUSTOMERS
// ═══════════════════════════════════════════════════════════
function buildCustomers(fm){
// Customer segments (approximate from filter)
const nrFiltered = filterArr(RAW.newReturningMonthly);
const newOrders = nrFiltered.filter(r=>r.IsNewCustomer===1).reduce((s,r)=>s+r.orders,0);
const retOrders = nrFiltered.filter(r=>r.IsNewCustomer===0).reduce((s,r)=>s+r.orders,0);
destroyChart('custSegChart');
charts['custSegChart'] = new Chart(document.getElementById('custSegChart'),{
type:'doughnut',data:{labels:['New Customer Orders','Returning Customer Orders'],
datasets:[{data:[newOrders,retOrders],backgroundColor:['#64748b','#6366f1']}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{color:'#64748b'}}}}
});
// Countries
destroyChart('countryChart');
const cFiltered = filterArr(RAW.countryMonthly);
const countryAgg = {};
cFiltered.forEach(r=>{
if(!countryAgg[r.CustomerCountry]) countryAgg[r.CustomerCountry]={orders:0,revenue:0};
countryAgg[r.CustomerCountry].orders += r.orders;
countryAgg[r.CustomerCountry].revenue += r.revenue;
});
const countries = Object.entries(countryAgg).map(([k,v])=>({name:k,...v})).sort((a,b)=>b.orders-a.orders).slice(0,10);
charts['countryChart'] = new Chart(document.getElementById('countryChart'),{
type:'bar',data:{labels:countries.map(c=>c.name),datasets:[{data:countries.map(c=>c.orders),backgroundColor:COLORS.map(c=>c+'cc')}]},
options:{...cDef,indexAxis:'y',plugins:{legend:{display:false}}}
});
// Cohort retention
const cohorts = (RAW.cohortRetention||[]).filter(c=>{
if(c.size < 100) return false;
// Filter cohorts within date range if applicable
if(FSTART && c.cohort < FSTART) return false;
if(FEND && c.cohort > FEND) return false;
return true;
});
let ch='<thead><tr><th>Cohort</th><th class="num">Size</th>';
for(let i=0;i<=12;i++) ch+=`<th class="num">M${i}</th>`;
ch+='</tr></thead><tbody>';
cohorts.forEach(c=>{
ch+=`<tr><td>${c.cohort}</td><td class="num">${fmt(c.size)}</td>`;
for(let i=0;i<=12;i++){
const v = c['m'+i];
if(v==null){ch+='<td class="num" style="color:#334155">-</td>';continue;}
const bg = v>50?'rgba(16,185,129,.3)':v>20?'rgba(16,185,129,.15)':v>10?'rgba(16,185,129,.07)':'transparent';
ch+=`<td class="num" style="background:${bg}">${pct(v)}</td>`;
}
ch+='</tr>';
});
document.getElementById('cohortTable').innerHTML=ch+'</tbody>';
}
// ═══════════════════════════════════════════════════════════
// LOAD DATA
// ═══════════════════════════════════════════════════════════
async function loadData(start, end){
try {
// Build API URL with optional date filters — data served from PostgreSQL
let url = '/api/dashboard/data';
const params = [];
if(start) params.push('start=' + encodeURIComponent(start));
if(end) params.push('end=' + encodeURIComponent(end));
if(params.length) url += '?' + params.join('&');
const t0 = performance.now();
const res = await fetch(url);
if(!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
RAW = await res.json();
const elapsed = ((performance.now()-t0)/1000).toFixed(2);
// Sort monthly data
RAW.monthly.sort((a,b) => a.YearMonth.localeCompare(b.YearMonth));
// Set date picker min/max from meta (full range)
if(RAW.meta && RAW.meta.dateMin){
document.getElementById('startDate').min = RAW.meta.dateMin;
document.getElementById('startDate').max = RAW.meta.dateMax;
document.getElementById('endDate').min = RAW.meta.dateMin;
document.getElementById('endDate').max = RAW.meta.dateMax;
}
document.getElementById('loadingMsg').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
if(!start && !end) document.getElementById('presetAll').classList.add('active');
// Show data source badge
const src = RAW._source || 'json';
const qt = RAW._query_time || elapsed+'s';
const badge = document.getElementById('dbBadge');
if(badge) badge.innerHTML = `<span style="font-size:10px;color:var(--green);background:rgba(16,185,129,.1);padding:2px 8px;border-radius:4px;font-family:monospace">⚡ PostgreSQL · ${qt} · ${RAW.monthly.length} months</span>`;
buildAll();
} catch(err) {
document.getElementById('loadingMsg').innerHTML = `
<div style="color:var(--red);font-size:16px;margin-bottom:12px">⚠ Failed to load data</div>
<div style="color:var(--muted);font-size:13px">${err.message}</div>
<div style="color:var(--muted);font-size:12px;margin-top:12px">
Data is served from PostgreSQL via <code>/api/dashboard/data</code>.<br>
Check that the database service is running.
</div>
`;
}
}
loadData();
</script>
</body>
</html>