Skip to main content

KPI Dashboard

Build a dashboard with metric cards and a bar chart using Informer dataset queries.

What You'll Build

A sales dashboard with:

  • 4 metric cards (total revenue, order count, avg order value, customer count)
  • A bar chart showing revenue by product category
  • Data from a single dataset using Elasticsearch aggregations
  • Theme support (light/dark)
  • Print-ready layout

Prerequisites

  • Completed Getting Started
  • A dataset in Informer with sales data (e.g., orders with amount, customerId, category fields)

Example Prompts for Claude Code

Instead of manually writing the code, describe what you want to Claude Code. Here are example prompts:

Initial build:

"Build a sales dashboard that queries the admin:sales-data dataset. Show 4 metric cards: total revenue, order count, average order value, and unique customer count. Below the metrics, add a bar chart showing revenue by product category. Use ECharts for the chart. Style it with a modern dark theme. Make sure informer.yaml grants access to admin:sales-data."

Refinements:

"The chart colors don't match our brand. Use #6366f1 for the bars."

"Add responsive breakpoints so metrics stack on mobile."

"Include print styles so this looks good when exported to PDF."

"Make the chart update when window resizes."

Data Access Configuration

Claude will update informer.yaml when you ask, or you can do it manually:

access:
datasets:
- admin:sales-data

Replace admin:sales-data with your actual dataset ID.

What Claude Will Generate

When you prompt Claude Code, it will generate something like the code below. You don't need to copy-paste this — it's shown as reference so you understand what gets built.

HTML Structure

Claude will scaffold a dashboard layout like this:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sales Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="dashboard">
<header>
<h1>Sales Dashboard</h1>
<p class="subtitle">Revenue and orders overview</p>
</header>

<div class="metrics">
<div class="metric-card">
<span class="metric-value" id="totalRevenue">--</span>
<span class="metric-label">Total Revenue</span>
</div>
<div class="metric-card">
<span class="metric-value" id="orderCount">--</span>
<span class="metric-label">Orders</span>
</div>
<div class="metric-card">
<span class="metric-value" id="avgOrder">--</span>
<span class="metric-label">Avg Order Value</span>
</div>
<div class="metric-card">
<span class="metric-value" id="customerCount">--</span>
<span class="metric-label">Customers</span>
</div>
</div>

<div class="content">
<h2>Revenue by Category</h2>
<div id="chart" style="width: 100%; height: 400px;"></div>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script type="module" src="main.js"></script>
</body>
</html>

Styles

Claude will create theme-aware styles like this:

* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
--bg: #0f172a;
--bg-card: #1e293b;
--border: #334155;
--text: #f1f5f9;
--text-muted: #94a3b8;
--primary: #6366f1;
}

[data-theme="light"] {
--bg: #ffffff;
--bg-card: #f8fafc;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #64748b;
--primary: #4f46e5;
}

body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}

.dashboard {
max-width: 1400px;
margin: 0 auto;
padding: 32px 24px;
}

header {
margin-bottom: 32px;
}

header h1 {
font-size: 28px;
font-weight: 700;
}

.subtitle {
color: var(--text-muted);
margin-top: 4px;
}

.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}

.metric-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
text-align: center;
}

.metric-value {
display: block;
font-size: 32px;
font-weight: 700;
color: var(--primary);
}

.metric-label {
display: block;
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}

.content {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}

.content h2 {
font-size: 18px;
margin-bottom: 16px;
}

@media (max-width: 768px) {
.metrics { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 480px) {
.metrics { grid-template-columns: 1fr; }
}

/* Print styles */
@media print {
body {
background: white;
color: black;
}

.metric-card,
.content {
break-inside: avoid;
border: 1px solid #ddd;
}
}

JavaScript

Claude will write the data-fetching and rendering logic:

window.informerReady = false;

// Apply theme from Informer context
const theme = window.__INFORMER__?.theme || 'light';
document.documentElement.setAttribute('data-theme', theme);

async function init() {
try {
await loadData();
window.informerReady = true;
} catch (err) {
console.error('Failed to load data:', err);
document.querySelector('.dashboard').innerHTML =
`<div style="padding: 40px; text-align: center; color: #ef4444;">
Error loading data: ${err.message}
</div>`;
}
}

async function loadData() {
// Replace with your dataset ID
const datasetId = 'admin:sales-data';

// Query with aggregations for metrics and chart data
const response = await fetch(`/api/datasets/${datasetId}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: { match_all: {} },
size: 0, // Don't return documents, just aggregations
aggs: {
totalRevenue: {
sum: { field: 'amount' }
},
orderCount: {
value_count: { field: 'id' }
},
avgOrder: {
avg: { field: 'amount' }
},
customerCount: {
cardinality: { field: 'customerId' }
},
byCategory: {
terms: { field: 'category', size: 10 },
aggs: {
revenue: { sum: { field: 'amount' } }
}
}
}
})
});

if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}

const result = await response.json();
const aggs = result.aggregations;

// Update metric cards
document.getElementById('totalRevenue').textContent =
formatCurrency(aggs.totalRevenue.value);
document.getElementById('orderCount').textContent =
aggs.orderCount.value.toLocaleString();
document.getElementById('avgOrder').textContent =
formatCurrency(aggs.avgOrder.value);
document.getElementById('customerCount').textContent =
aggs.customerCount.value.toLocaleString();

// Render chart
renderChart(aggs.byCategory.buckets);
}

function renderChart(buckets) {
const chart = echarts.init(document.getElementById('chart'), theme === 'dark' ? 'dark' : null);

const categories = buckets.map(b => b.key);
const revenues = buckets.map(b => b.revenue.value);

chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params[0];
return `${item.name}<br/>${formatCurrency(item.value)}`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: categories,
axisLabel: { rotate: 45 }
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value}`;
}
}
},
series: [{
name: 'Revenue',
type: 'bar',
data: revenues,
itemStyle: {
color: theme === 'dark' ? '#6366f1' : '#4f46e5'
}
}]
});

// Responsive resize
window.addEventListener('resize', () => chart.resize());
}

function formatCurrency(val) {
if (isNaN(val)) return '--';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(val);
}

init();

Understanding the Query

The dataset search uses Elasticsearch aggregations to compute metrics:

{
query: { match_all: {} }, // Query all records
size: 0, // Don't return documents (just aggregations)
aggs: {
totalRevenue: { sum: { field: 'amount' } }, // Sum all amounts
orderCount: { value_count: { field: 'id' } }, // Count records
avgOrder: { avg: { field: 'amount' } }, // Average amount
customerCount: { cardinality: { field: 'customerId' } }, // Unique customers
byCategory: {
terms: { field: 'category', size: 10 }, // Group by category
aggs: {
revenue: { sum: { field: 'amount' } } // Sum per category
}
}
}
}

The response structure:

{
hits: { total: 1234, hits: [] },
aggregations: {
totalRevenue: { value: 1234567 },
orderCount: { value: 1234 },
avgOrder: { value: 1001.34 },
customerCount: { value: 456 },
byCategory: {
buckets: [
{ key: 'Electronics', doc_count: 234, revenue: { value: 567890 } },
{ key: 'Clothing', doc_count: 456, revenue: { value: 345678 } }
]
}
}
}

Iterating on the Dashboard

Once Claude builds the initial version, you can refine it with more prompts:

Add features:

"Add a loading spinner while data fetches."

"Show a trend indicator (up/down arrow) on each metric card."

"Add animation when the chart loads."

Change styling:

"Use a light theme instead of dark."

"Make the metric cards use a gradient background."

"Increase font size on the metric values."

Improve data visualization:

"Sort the chart categories by revenue descending."

"Add a tooltip formatter that shows percentage of total."

"Limit the chart to top 10 categories."

Test Locally

npm run dev

Open http://localhost:5173 and verify:

  • Metrics load and display correctly
  • Chart renders with category data
  • Theme matches your vite.config.js setting
  • No console errors

Deploy

npm run deploy

The app will be uploaded to Informer and accessible at:

https://your-instance.entrinsik.com/reports/r/{owner}:{slug}

The app includes window.informerReady to signal when rendering is complete. This is used by the PDF export endpoint (POST /api/reports/\{id\}/_print):

  1. Informer opens the app in a headless browser
  2. Waits for network requests to complete
  3. Waits for window.informerReady = true
  4. Adds .print class to <html>
  5. Captures as PDF using print media

The CSS includes @media print rules to optimize the layout for PDF export.

Next Steps

Continue to Multi-Dataset Dashboard to build your first data-driven app.