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,categoryfields)
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.jssetting - 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}
Print Support
The app includes window.informerReady to signal when rendering is complete. This is used by the PDF export endpoint (POST /api/reports/\{id\}/_print):
- Informer opens the app in a headless browser
- Waits for network requests to complete
- Waits for
window.informerReady = true - Adds
.printclass to<html> - 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.