Agent Template

Sales Data Explorer (CSV)

Upload a CSV of sales orders (date, region, product, amount). Explore insights with summary stat cards, quick charts (Summary, Top products, Monthly trend), and a natural-language Q&A chat for non-technical sales managers. Charts render inline; answers are concise and business-friendly.

sales csv charts exploration business-intelligence

Preview Mode

This is a preview with sample data. The template uses placeholders like which will be replaced with actual agent data.

About This Template

Sales Data Explorer (CSV) is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.

Topics sales csv charts exploration business-intelligence
Template Preview

Template Metadata

Slug
sales-data-explorer-csv
Created By
ozzo
Created
Jun 19, 2026
Usage Count
0

Tags

sales csv charts exploration business-intelligence

Code Statistics

HTML Lines
95
CSS Lines
62
JS Lines
252
Python Lines
296

Source Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{{ agent_name }}</title>
  <style>
    {{ css_code }}
  </style>
</head>
<body>
  <header class="app-header" role="banner">
    <div class="brand">
      <div class="logo">📈</div>
      <div class="titles">
        <h1 class="app-title">{{ agent_name }}</h1>
        <p class="app-subtitle">{{ description }}</p>
      </div>
    </div>
    <div class="actions">
      <button id="btn-summary" class="action" disabled>Summary</button>
      <button id="btn-top-products" class="action" disabled>Top products</button>
      <button id="btn-monthly-trend" class="action" disabled>Monthly trend</button>
    </div>
  </header>

  <main class="layout" role="main">
    <section class="left-panel" aria-label="Upload and Summary">
      <div class="card upload-card">
        <h2>Upload sales CSV</h2>
        <p class="muted">Columns required: date, region, product, amount</p>
        <label class="upload-drop" for="file-input">
          <input id="file-input" type="file" accept=".csv" />
          <span id="upload-cta">Drop or choose a .csv file</span>
        </label>
        <div id="upload-status" class="status muted"></div>
      </div>

      <div class="card stats-card">
        <h2>Sales summary</h2>
        <div id="summary-empty" class="empty">
          <p>Upload a sales CSV to see key stats:</p>
          <ul>
            <li>Total revenue and orders</li>
            <li>Best region and products</li>
            <li>Date range and trends</li>
            <li>Quick charts you can share</li>
          </ul>
        </div>
        <div id="stats-grid" class="stats-grid hidden"></div>
      </div>

      <div id="model-hint" class="card hint hidden"></div>
    </section>

    <section class="center-panel" aria-label="Charts and Visuals">
      <div class="card visuals-card">
        <div class="visuals-header">
          <h2 id="visuals-title">Sales visuals</h2>
        </div>
        <div id="chart-area" class="chart-area">
          <div id="chart-empty" class="empty">
            <p>No chart yet.</p>
            <p class="muted">Use the fast-path buttons above to render a chart for your sales data.</p>
          </div>
          <div id="chart-canvas" class="chart-canvas hidden"></div>
        </div>
      </div>
    </section>

    <aside class="right-panel" aria-label="Sales Q&A Chat">
      <div class="card chat-card">
        <div class="chat-header">
          <h2>Sales Q&A</h2>
          <div class="chips" id="example-chips"></div>
        </div>
        <div id="results-container" class="chat-log" aria-live="polite"></div>
        <form id="chat-form" class="chat-form" autocomplete="off">
          <input id="chat-input" type="text" placeholder="Ask a sales question (e.g., Which region grew fastest last quarter?)" />
          <button id="chat-send" type="submit" class="primary">Ask</button>
        </form>
      </div>
    </aside>
  </main>

  <div id="overlay" class="overlay hidden" aria-hidden="true">
    <div class="spinner" aria-label="Loading"></div>
    <div class="overlay-text">Working on your sales data…</div>
  </div>

  <script>
    {{ js_code }}
  </script>
</body>
</html>
:root{--bg:#0b1020;--panel:#121934;--card:#151f44;--muted:#90a0c0;--text:#e6eeff;--accent:#5aa7ff;--accent-2:#7ed9a8;--warn:#ffb86b;--danger:#ff6b8b;--ring:0 0 0 3px rgba(90,167,255,.35);--radius:14px;--shadow:0 8px 28px rgba(0,0,0,.35);--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--sans:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,"Helvetica Neue",Arial,"Apple Color Emoji","Segoe UI Emoji"}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:linear-gradient(180deg,#0b1020 0%,#0e1430 100%);color:var(--text);font-family:var(--sans)}
.app-header{position:relative;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px;background:rgba(18,25,52,.7);backdrop-filter:saturate(1.2) blur(6px);border-bottom:1px solid rgba(255,255,255,.06)}
.brand{display:flex;align-items:center;gap:12px}
.logo{font-size:28px}
.app-title{margin:0;font-size:18px;font-weight:700}
.app-subtitle{margin:2px 0 0 0;font-size:12px;color:var(--muted);max-width:720px}
.actions{display:flex;gap:8px;flex-wrap:wrap}
.action{background:#1b2b5e;border:1px solid rgba(255,255,255,.08);color:var(--text);padding:10px 14px;border-radius:10px;cursor:pointer;transition:.2s;box-shadow:inset 0 -2px 0 rgba(255,255,255,.03)}
.action:hover{background:#20336e}
.action:disabled{opacity:.45;cursor:not-allowed}

.layout{display:grid;grid-template-columns:320px 1fr 420px;gap:18px;padding:18px;align-items:start}
.left-panel,.center-panel,.right-panel{min-width:0}
.card{background:var(--card);border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px}
.muted{color:var(--muted)}

.upload-card .upload-drop{display:block;margin-top:8px;background:#0f1736;border:2px dashed rgba(255,255,255,.12);border-radius:12px;padding:18px;text-align:center;color:var(--muted);cursor:pointer}
.upload-card input[type=file]{display:none}
.status{margin-top:8px;font-size:12px}

.stats-card h2,.visuals-card h2,.chat-card h2{margin:2px 0 8px 0}
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.stat{background:#0f1736;border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:12px}
.stat .label{color:var(--muted);font-size:12px}
.stat .value{font-size:18px;font-weight:700;margin-top:4px}
.best{color:var(--accent-2)}

.hint{font-size:12px;color:var(--muted)}

.visuals-header{display:flex;align-items:center;justify-content:space-between}
.chart-area{min-height:280px}
.chart-canvas img{max-width:100%;height:auto;border-radius:10px;border:1px solid rgba(255,255,255,.08);background:#0f1736}

.chat-card{display:flex;flex-direction:column;height:calc(100vh - 160px);min-height:520px}
.chat-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.chip{background:#10214b;border:1px solid rgba(255,255,255,.08);color:var(--text);padding:6px 10px;border-radius:999px;font-size:12px;cursor:pointer}
.chat-log{flex:1;overflow:auto;background:#0f1736;border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:12px}
.msg{display:flex;gap:10px;margin:8px 0}
.msg .avatar{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#1a2a5c}
.msg.user .avatar{background:#2a4b8f}
.msg .bubble{flex:1;background:#0e1a3c;border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:10px;white-space:pre-wrap;word-wrap:break-word}
.msg.assistant .bubble{background:#0e1a30}
.chat-form{display:flex;gap:8px;margin-top:10px}
.chat-form input{flex:1;padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,.08);background:#0f1736;color:var(--text)}
.chat-form input:focus{outline:none;box-shadow:var(--ring)}
.chat-form .primary{background:var(--accent);color:#091326;border:none;border-radius:10px;padding:10px 14px;cursor:pointer;font-weight:700}
.chat-form .primary:disabled{opacity:.5;cursor:not-allowed}

.empty{opacity:.8;border:1px dashed rgba(255,255,255,.12);border-radius:12px;padding:12px}
.hidden{display:none !important}

.overlay{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(9,13,30,.55);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;z-index:50}
.spinner{width:38px;height:38px;border-radius:50%;border:4px solid rgba(255,255,255,.15);border-top-color:var(--accent);animation:spin 1s linear infinite}
.overlay-text{color:var(--text);font-weight:600}
@keyframes spin{to{transform:rotate(360deg)}}

@media (max-width:1200px){.layout{grid-template-columns:1fr 1fr}.right-panel{grid-column:1/3}}
@media (max-width:768px){.layout{grid-template-columns:1fr;gap:12px}.chat-card{height:auto}}
(function(){
  const $ = (sel, el=document) => el.querySelector(sel);
  const $$ = (sel, el=document) => Array.from(el.querySelectorAll(sel));
  const overlay = $('#overlay');
  const fileInput = $('#file-input');
  const uploadStatus = $('#upload-status');
  const statsGrid = $('#stats-grid');
  const summaryEmpty = $('#summary-empty');
  const chartEmpty = $('#chart-empty');
  const chartCanvas = $('#chart-canvas');
  const visualsTitle = $('#visuals-title');
  const btnSummary = $('#btn-summary');
  const btnTopProducts = $('#btn-top-products');
  const btnMonthlyTrend = $('#btn-monthly-trend');
  const modelHint = $('#model-hint');
  const chatForm = $('#chat-form');
  const chatInput = $('#chat-input');
  const chatSend = $('#chat-send');
  const chatLog = $('#results-container');
  const exampleChips = $('#example-chips');

  const EXAMPLES = [
    'Which region grew fastest last quarter?',
    'Top 5 products by revenue year-to-date.',
    'Show the monthly sales trend for this year.',
    'Any anomalies in March compared to February?'
  ];

  function escapeHtml(str){
    if(!str && str !== 0) return '';
    return String(str)
      .replace(/&/g,'&amp;')
      .replace(/</g,'&lt;')
      .replace(/>/g,'&gt;')
      .replace(/\"/g,'&quot;')
      .replace(/'/g,'&#39;');
  }

  function showOverlay(show){
    overlay.classList.toggle('hidden', !show);
    overlay.setAttribute('aria-hidden', show ? 'false' : 'true');
    chatSend.disabled = show || chatSend.disabled;
  }

  function addChip(text){
    const b = document.createElement('button');
    b.type = 'button';
    b.className = 'chip';
    b.textContent = text;
    b.addEventListener('click', ()=>{
      chatInput.value = text;
      chatInput.focus();
    });
    exampleChips.appendChild(b);
  }

  function addMessage(type, content){
    const row = document.createElement('div');
    row.className = 'msg ' + type;
    const avatar = document.createElement('div');
    avatar.className = 'avatar';
    avatar.textContent = type === 'user' ? '🧑‍💼' : '🤖';
    const bubble = document.createElement('div');
    bubble.className = 'bubble';
    bubble.textContent = content; // safe: we do not inject HTML
    row.appendChild(avatar); row.appendChild(bubble);
    chatLog.appendChild(row);
    chatLog.scrollTop = chatLog.scrollHeight;
    return bubble;
  }
  window.addMessage = addMessage; // enable local streaming hook

  function clearChart(){
    chartCanvas.innerHTML = '';
    chartCanvas.classList.add('hidden');
    chartEmpty.classList.remove('hidden');
  }

  function renderChart(dataUri, title){
    if(!dataUri){ clearChart(); return; }
    chartCanvas.innerHTML = '';
    const img = document.createElement('img');
    img.alt = title || 'Sales chart';
    img.src = dataUri;
    chartCanvas.appendChild(img);
    chartCanvas.classList.remove('hidden');
    chartEmpty.classList.add('hidden');
    if(title) visualsTitle.textContent = title;
  }

  function renderSummary(summary){
    if(!summary){
      statsGrid.classList.add('hidden');
      summaryEmpty.classList.remove('hidden');
      return;
    }
    const s = summary; // object
    statsGrid.innerHTML = '';
    const items = [
      {label:'Total revenue', value: s.total_revenue_fmt},
      {label:'Total orders', value: s.total_orders.toLocaleString()},
      {label:'Avg order value', value: s.avg_order_value_fmt},
      {label:'Date range', value: s.date_range},
      {label:'Regions', value: s.regions_count.toString()},
      {label:'Products', value: s.products_count.toString()},
      {label:'Top region', value: s.top_region || '—', cls:'best'},
      {label:'Last vs prev month', value: s.mom_change || '—'}
    ];
    items.forEach(it=>{
      const d = document.createElement('div');
      d.className = 'stat';
      const l = document.createElement('div'); l.className = 'label'; l.textContent = it.label;
      const v = document.createElement('div'); v.className = 'value' + (it.cls?(' '+it.cls):''); v.textContent = it.value;
      d.appendChild(l); d.appendChild(v);
      statsGrid.appendChild(d);
    });
    statsGrid.classList.remove('hidden');
    summaryEmpty.classList.add('hidden');
  }

  function setButtonsEnabled(enabled){
    [btnSummary, btnTopProducts, btnMonthlyTrend].forEach(b=> b.disabled = !enabled);
  }

  async function ensurePyodideReady(){
    if(window.pyodideReady){ await window.pyodideReady; return; }
    await new Promise(resolve => {
      if(document.readyState === 'complete'){
        // wait for pyodide-ready event
      }
      const onReady = ()=>{ document.removeEventListener('pyodide-ready', onReady); resolve(); };
      document.addEventListener('pyodide-ready', onReady);
    });
  }

  function isLocalModelRequiredButMissing(){
    // If local provider, a model selector is injected. Require a loaded model.
    try{
      if(window.agentManager && !window.agentManager.isLoaded){ return true; }
    }catch(e){/* ignore */}
    return false;
  }

  async function ingestCsvText(text){
    window.pyodide.globals.set('csv_text', text);
    const res = await window.pyodide.runPythonAsync('_ingest_csv(csv_text)');
    const data = JSON.parse(res);
    if(!data.success){ throw new Error(data.error || 'Failed to load CSV'); }
    return data.summary;
  }

  async function doSummary(){
    showOverlay(true);
    try{
      const res = await window.pyodide.runPythonAsync('get_summary_cards()');
      const obj = JSON.parse(res);
      renderSummary(obj);
      visualsTitle.textContent = 'Sales visuals';
    }catch(err){
      console.error(err);
      uploadStatus.textContent = 'Error: '+ (err.message || err.toString());
    }finally{ showOverlay(false); }
  }

  async function doTopProducts(){
    showOverlay(true);
    try{
      const uri = await window.pyodide.runPythonAsync('chart_top_products(5)');
      renderChart(uri, 'Top 5 products by revenue');
    }catch(err){ renderChart('', ''); uploadStatus.textContent = 'Error: '+(err.message||err); }
    finally{ showOverlay(false); }
  }

  async function doMonthlyTrend(){
    showOverlay(true);
    try{
      const uri = await window.pyodide.runPythonAsync('chart_monthly_trend()');
      renderChart(uri, 'Monthly revenue trend');
    }catch(err){ renderChart('', ''); uploadStatus.textContent = 'Error: '+(err.message||err); }
    finally{ showOverlay(false); }
  }

  async function handleChatSubmit(e){
    e.preventDefault();
    const q = chatInput.value.trim();
    if(!q) return;
    if(isLocalModelRequiredButMissing()){
      modelHint.classList.remove('hidden');
      modelHint.textContent = 'Load a local model from the top bar to use Sales Q&A.';
      return;
    }
    addMessage('user', q);
    chatInput.value = '';
    chatSend.disabled = true;
    try{
      window.pyodide.globals.set('user_query', q);
      // Let the platform's default dispatcher handle tools+LLM
      const out = await window.pyodide.runPythonAsync('await process_user_query(user_query)');
      addMessage('assistant', escapeHtml(out));
    }catch(err){
      addMessage('assistant', 'Error: ' + (err.message || String(err)));
    }finally{
      chatSend.disabled = false;
    }
  }

  function initChips(){ EXAMPLES.forEach(addChip); }

  async function init(){
    initChips();
    // Initial model hint for local-only
    if(isLocalModelRequiredButMissing()){
      modelHint.classList.remove('hidden');
      modelHint.textContent = 'Tip: Select and load a local model from the top bar to enable Q&A.';
    }

    fileInput.addEventListener('change', async (e)=>{
      const file = e.target.files && e.target.files[0];
      if(!file){ return; }
      const name = file.name || 'file.csv';
      uploadStatus.textContent = 'Reading '+name+'…';
      try{
        const text = await file.text();
        showOverlay(true);
        const summary = await ingestCsvText(text);
        uploadStatus.textContent = 'Loaded '+ name + ' • ' + summary.total_orders.toLocaleString() + ' orders';
        renderSummary(summary);
        setButtonsEnabled(true);
      }catch(err){
        console.error(err);
        uploadStatus.textContent = 'Error: ' + (err.message || String(err));
        renderSummary(null);
        setButtonsEnabled(false);
        clearChart();
      }finally{
        showOverlay(false);
      }
    });

    btnSummary.addEventListener('click', doSummary);
    btnTopProducts.addEventListener('click', doTopProducts);
    btnMonthlyTrend.addEventListener('click', doMonthlyTrend);

    chatForm.addEventListener('submit', handleChatSubmit);
  }

  (async ()=>{
    try{ await ensurePyodideReady(); }catch(e){ console.error('Pyodide wait failed', e); }
    document.dispatchEvent(new Event('agent-ui-ready'));
    init();
  })();
})();
import json
import math
import io
import base64
from typing import Optional

import pandas as pd
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

# Runtime state
_STATE = {
    'df': None,
    'summary': None,
}

_MAX_CHARS = int('[[[MAX_CHARS|6000]]]')


def _truncate(s: str, max_chars: int = _MAX_CHARS) -> str:
    if s is None:
        return ''
    if len(s) <= max_chars:
        return s
    return s[:max_chars] + f"\n... (truncated to {max_chars} chars)"


def _fmt_currency(x: float) -> str:
    try:
        return f"${x:,.0f}" if abs(x) >= 1000 else f"${x:,.2f}"
    except Exception:
        return str(x)


def _ensure_df() -> pd.DataFrame:
    if _STATE['df'] is None:
        raise ValueError('No dataset loaded. Upload a CSV first.')
    return _STATE['df']


def _parse_amount_col(df: pd.DataFrame) -> pd.DataFrame:
    # unify amount column
    cols = {c.lower().strip(): c for c in df.columns}
    cand = None
    for k in ['amount', 'revenue', 'sales', 'total', 'value']:  # fallbacks
        if k in cols:
            cand = cols[k]
            break
    if cand is None:
        raise ValueError('Missing amount/revenue column.')
    df = df.rename(columns={cand: 'amount'})
    df['amount'] = pd.to_numeric(df['amount'], errors='coerce')
    df = df.dropna(subset=['amount'])
    return df


def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
    cols = {c.lower().strip(): c for c in df.columns}
    # date
    dcol = None
    for k in ['date', 'order_date', 'orderdate', 'dt']:
        if k in cols:
            dcol = cols[k]
            break
    if dcol is None:
        raise ValueError('Missing date column.')
    df = df.rename(columns={dcol: 'date'})
    # region
    rcol = None
    for k in ['region', 'market', 'area']:
        if k in cols:
            rcol = cols[k]
            break
    if rcol is None:
        raise ValueError('Missing region column.')
    df = df.rename(columns={rcol: 'region'})
    # product
    pcol = None
    for k in ['product', 'sku', 'item']:
        if k in cols:
            pcol = cols[k]
            break
    if pcol is None:
        raise ValueError('Missing product column.')
    df = df.rename(columns={pcol: 'product'})
    # amount
    df = _parse_amount_col(df)

    # parse dates
    if not np.issubdtype(df['date'].dtype, np.datetime64):
        df['date'] = pd.to_datetime(df['date'], errors='coerce', utc=False)
    df = df.dropna(subset=['date'])
    df['date'] = df['date'].dt.tz_localize(None) if hasattr(df['date'].dt, 'tz_localize') else df['date']

    # add calendar helpers
    df['year'] = df['date'].dt.year
    df['month'] = df['date'].dt.to_period('M').dt.to_timestamp()
    df['quarter'] = df['date'].dt.to_period('Q').astype(str)
    return df


def _build_summary(df: pd.DataFrame) -> dict:
    total_orders = int(len(df))
    total_revenue = float(df['amount'].sum())
    avg_order = float(df['amount'].mean()) if total_orders else 0.0
    start = df['date'].min()
    end = df['date'].max()
    regions_count = int(df['region'].nunique())
    products_count = int(df['product'].nunique())

    by_region = df.groupby('region', as_index=False)['amount'].sum().sort_values('amount', ascending=False)
    top_region = by_region.iloc[0]['region'] if len(by_region) else None

    by_month = df.groupby('month', as_index=False)['amount'].sum().sort_values('month')
    mom_change = None
    if len(by_month) >= 2:
        last, prev = by_month.iloc[-1]['amount'], by_month.iloc[-2]['amount']
        if prev != 0:
            chg = (last - prev) / prev * 100.0
            arrow = '▲' if chg >= 0 else '▼'
            mom_change = f"{arrow} {chg:.1f}% vs prev month"
        else:
            mom_change = "—"

    summary = {
        'total_orders': total_orders,
        'total_revenue': total_revenue,
        'avg_order_value': avg_order,
        'total_revenue_fmt': _fmt_currency(total_revenue),
        'avg_order_value_fmt': _fmt_currency(avg_order),
        'date_range': f"{start.date()} → {end.date()}" if start is not pd.NaT and end is not pd.NaT else '—',
        'regions_count': regions_count,
        'products_count': products_count,
        'top_region': top_region,
        'mom_change': mom_change,
    }
    return summary


def _fig_to_data_uri(fig) -> str:
    buf = io.BytesIO()
    fig.savefig(buf, format='png', bbox_inches='tight', dpi=140, facecolor='#0f1736')
    plt.close(fig)
    buf.seek(0)
    b64 = base64.b64encode(buf.read()).decode('ascii')
    return f"data:image/png;base64,{b64}"


def _style_axes(ax):
    ax.grid(True, color='#27406b', alpha=0.5, linestyle='--', linewidth=0.6)
    ax.set_facecolor('#0f1736')
    for spine in ax.spines.values():
        spine.set_color('#2a3a6b')
    ax.tick_params(colors='#e6eeff')


def _ingest_csv(csv_text: str) -> str:
    """Helper to load CSV text into the agent state.

    Args:
      csv_text: CSV content as a UTF-8 string.
    """
    try:
        df = pd.read_csv(io.StringIO(csv_text))
        df = _standardize_columns(df)
        _STATE['df'] = df
        summary = _build_summary(df)
        _STATE['summary'] = summary
        return json.dumps({'success': True, 'summary': summary})
    except Exception as e:
        _STATE['df'] = None
        _STATE['summary'] = None
        return json.dumps({'success': False, 'error': str(e)})


# Public tools (auto-exposed)

def get_summary_cards() -> str:
    """Return key sales summary metrics as JSON.

    Args:
      None
    """
    df = _ensure_df()
    summary = _build_summary(df)
    _STATE['summary'] = summary
    return json.dumps(summary)


def chart_monthly_trend() -> str:
    """Render a monthly revenue trend chart. Returns a PNG data URI string.

    Args:
      None
    """
    df = _ensure_df()
    monthly = df.groupby('month', as_index=False)['amount'].sum().sort_values('month')
    fig, ax = plt.subplots(figsize=(6.6, 3.4))
    _style_axes(ax)
    ax.plot(monthly['month'], monthly['amount'], color='#5aa7ff', linewidth=2.0, marker='o', markersize=3)
    ax.set_title('Monthly Revenue', color='#e6eeff')
    ax.set_xlabel('Month', color='#e6eeff')
    ax.set_ylabel('Revenue', color='#e6eeff')
    fig.autofmt_xdate()
    return _fig_to_data_uri(fig)


def chart_top_products(n: int = 5) -> str:
    """Render a bar chart of top-N products by revenue. Returns a PNG data URI.

    Args:
      n: Number of products to include.
    """
    df = _ensure_df()
    top = (
        df.groupby('product', as_index=False)['amount']
        .sum()
        .sort_values('amount', ascending=False)
        .head(int(n))
        .sort_values('amount', ascending=True)
    )
    fig, ax = plt.subplots(figsize=(6.6, max(3.0, 0.38*len(top)+2)))
    _style_axes(ax)
    ax.barh(top['product'], top['amount'], color='#7ed9a8')
    ax.set_title(f'Top {len(top)} Products by Revenue', color='#e6eeff')
    ax.set_xlabel('Revenue', color='#e6eeff')
    return _fig_to_data_uri(fig)


def chart_revenue_by_region() -> str:
    """Render a bar chart of revenue by region. Returns a PNG data URI.

    Args:
      None
    """
    df = _ensure_df()
    reg = df.groupby('region', as_index=False)['amount'].sum().sort_values('amount', ascending=True)
    fig, ax = plt.subplots(figsize=(6.6, max(3.0, 0.38*len(reg)+2)))
    _style_axes(ax)
    ax.barh(reg['region'], reg['amount'], color='#5aa7ff')
    ax.set_title('Revenue by Region', color='#e6eeff')
    ax.set_xlabel('Revenue', color='#e6eeff')
    return _fig_to_data_uri(fig)


def pivot(group_by: str, period: str = 'M', top_n: int = 10) -> str:
    """Aggregate revenue by time period and a category. Returns compact JSON.

    Args:
      group_by: One of 'region' or 'product'.
      period: Time bucket. One of 'D','M','Q','Y'. Default 'M'.
      top_n: Limit number of groups by total revenue.
    """
    df = _ensure_df()
    group_by = (group_by or '').strip().lower()
    if group_by not in ('region', 'product'):
        raise ValueError("group_by must be 'region' or 'product'")
    period = (period or 'M').upper()
    if period not in ('D','M','Q','Y'):
        raise ValueError("period must be one of D,M,Q,Y")

    # Determine time key
    if period == 'D':
        df['_time'] = df['date'].dt.to_period('D').dt.to_timestamp()
    elif period == 'M':
        df['_time'] = df['date'].dt.to_period('M').dt.to_timestamp()
    elif period == 'Q':
        df['_time'] = df['date'].dt.to_period('Q').dt.to_timestamp()
    else:
        df['_time'] = pd.to_datetime(df['date'].dt.year.astype(str) + '-01-01')

    # Top-N groups by total
    totals = df.groupby(group_by, as_index=False)['amount'].sum().sort_values('amount', ascending=False)
    keep = set(totals.head(int(top_n))[group_by].tolist())
    df2 = df[df[group_by].isin(keep)].copy()

    agg = df2.groupby([group_by, '_time'], as_index=False)['amount'].sum().sort_values(['_time', group_by])
    out = {
        'group_by': group_by,
        'period': period,
        'rows': [
            {
                group_by: str(r[group_by]),
                'time': r['_time'].strftime('%Y-%m-%d'),
                'revenue': float(r['amount'])
            }
            for _, r in agg.iterrows()
        ]
    }
    # Cleanup temp column
    if '_time' in df:
        del df['_time']
    s = json.dumps(out)
    return _truncate(s)