Ask Claude for a Streamlit dashboard and you'll get something that runs and looks right within a minute. The problem is that "looks right" is where dashboards go wrong: a ROAS that averages daily ratios, a comparison period that overlaps the current one, a date picker that crashes halfway through a click. The way to get a KPI dashboard you can trust is to pin the metric definitions in your prompt, keep the math in a testable module, and run headless tests before anyone sees the numbers.
This post does that for an invented marketing CSV (183 daily rows, three channels, generated with a seeded script, nothing real). Everything below was run on 2026-10-09 with Python 3.13, Streamlit 1.65.0 and pandas 3.0.6 installed from PyPI. Two real bugs showed up during the test run, and both are described. The prompt is an untested template: I wrote the code by hand to the spec in it rather than capturing a model reply.
If you want an agent that answers questions about a database in plain English instead, see build a data analyst agent. This post is the other kind of tool: a fixed set of KPIs that must be right every time.
What should you tell Claude before it writes any dashboard code?
The prompt matters less than the spec inside it. Dashboards fail on definitions, so write the definitions yourself.
Untested template:
Write a Streamlit app (Streamlit 1.65, pandas 3, Python 3.13) that shows KPIs
from a CSV with columns: date (YYYY-MM-DD), channel, orders (int),
revenue (float), ad_spend (float), refunds (float).
Structure:
- kpi.py: pure functions, no Streamlit imports.
- app.py: UI only. Sidebar: file uploader, channel multiselect (key="channels"),
date range (key="range"). Main: five st.metric tiles, a line chart, a table.
- Tests (pytest + streamlit.testing.v1.AppTest) in test_dash.py.
KPI definitions (do not change them):
- Revenue = sum(revenue). Orders = sum(orders).
- AOV = sum(revenue) / sum(orders).
- ROAS = sum(revenue) / sum(ad_spend).
- Refund rate = sum(refunds) / sum(revenue).
Ratios come from summed numerators and denominators, never from averaging
daily ratios. Return None when a denominator is zero.
Comparison: the previous period is the same number of days ending the day
before the selected start. If the file has no earlier rows, show no delta.
Edge cases to handle and test: empty selection, a date range with only one
date chosen, a CSV missing a required column, zero ad spend.
Resolve the sample file path relative to app.py, not the working directory.
Do not add features I did not ask for.
Notice the last three lines. They come from the two bugs below, which a quick read of the generated code would have missed.
Where does the KPI math live?
In kpi.py, with no Streamlit import, so plain pytest can test it. This is the file as I ran it.
import pandas as pd
REQUIRED = ["date", "channel", "orders", "revenue", "ad_spend", "refunds"]
def load(path_or_buf) -> pd.DataFrame:
df = pd.read_csv(path_or_buf)
missing = [c for c in REQUIRED if c not in df.columns]
if missing:
raise ValueError(f"missing columns: {missing}")
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="raise")
for c in ["orders", "revenue", "ad_spend", "refunds"]:
df[c] = pd.to_numeric(df[c], errors="raise")
return df
def kpis(df: pd.DataFrame) -> dict:
"""Ratios are computed from summed numerators and denominators, never by averaging daily ratios."""
orders = df["orders"].sum()
revenue = df["revenue"].sum()
spend = df["ad_spend"].sum()
refunds = df["refunds"].sum()
return {
"revenue": revenue,
"orders": int(orders),
"aov": revenue / orders if orders else None,
"roas": revenue / spend if spend else None,
"refund_rate": refunds / revenue if revenue else None,
}
def previous_period(df_all: pd.DataFrame, start, end) -> pd.DataFrame:
"""The window of equal length that ends the day before `start`."""
span = (pd.Timestamp(end) - pd.Timestamp(start)).days + 1
prev_end = pd.Timestamp(start) - pd.Timedelta(days=1)
prev_start = prev_end - pd.Timedelta(days=span - 1)
return df_all[(df_all["date"] >= prev_start) & (df_all["date"] <= prev_end)]
def pct_change(now, before):
if now is None or before in (None, 0):
return None
return (now - before) / before
Two choices are worth stealing. load raises ValueError with the list of missing columns, so the app can show a clear message instead of a stack trace. And format="%Y-%m-%d" on the date parse means a file with day-first dates fails loudly rather than being read the wrong way; the CSV cleaning post covers what to do when your data isn't that tidy.
What does the Streamlit app look like?
app.py is UI only. Everything the user can change has a key, which is what lets the tests drive it.
from pathlib import Path
import streamlit as st
from kpi import kpis, load, pct_change, previous_period
st.set_page_config(page_title="Marketing KPIs", layout="wide")
st.title("Marketing KPIs")
@st.cache_data
def read(path):
return load(path)
upload = st.sidebar.file_uploader("CSV", type="csv")
try:
df_all = load(upload) if upload else read(str(Path(__file__).parent / "sales.csv"))
except ValueError as e:
st.error(f"Could not read the file: {e}")
st.stop()
channels = st.sidebar.multiselect("Channel", sorted(df_all["channel"].unique()),
default=sorted(df_all["channel"].unique()), key="channels")
lo, hi = df_all["date"].min().date(), df_all["date"].max().date()
picked = st.sidebar.date_input("Date range", (lo, hi), min_value=lo, max_value=hi, key="range")
if len(picked) != 2: # while the user is mid-selection Streamlit returns a 1-tuple
st.info("Pick an end date.")
st.stop()
start, end = picked
sel = df_all[df_all["channel"].isin(channels)]
cur = sel[(sel["date"] >= str(start)) & (sel["date"] <= str(end))]
prev = previous_period(sel, start, end)
if cur.empty:
st.warning("No rows for this selection.")
st.stop()
k, p = kpis(cur), kpis(prev)
cols = st.columns(5)
spec = [("Revenue", "revenue", "{:,.0f}"), ("Orders", "orders", "{:,.0f}"),
("AOV", "aov", "{:,.0f}"), ("ROAS", "roas", "{:.2f}"), ("Refund rate", "refund_rate", "{:.1%}")]
for col, (label, key, fmt) in zip(cols, spec):
d = pct_change(k[key], p[key]) if not prev.empty else None
col.metric(label, fmt.format(k[key]) if k[key] is not None else "n/a",
f"{d:+.1%}" if d is not None else None)
if prev.empty:
st.caption("No earlier period of the same length in the file, so no comparison is shown.")
daily = cur.groupby("date")[["revenue", "ad_spend"]].sum()
st.line_chart(daily)
st.dataframe(cur.groupby("channel")[["orders", "revenue", "ad_spend", "refunds"]].sum())
The sample data comes from a seeded generator (numpy.random.default_rng(7), 61 days from 2026-08-01 to 2026-09-30, three channels, Poisson order counts). It's invented, so the numbers say nothing about any real business.
How do you test a Streamlit dashboard without a browser?
Streamlit ships a headless testing class, AppTest, in streamlit.testing.v1. Its documentation (read 2026-10-09) describes it as simulating a running app so you can set up, manipulate and inspect the app contents through an API instead of a browser UI, and notes you can run such tests with a tool like pytest. The documented pattern is AppTest.from_file("streamlit_app.py"), .run(), then assert not at.exception.
Here is the test file, six tests in all: three on the math, three driving the app.
import io
import pandas as pd
from streamlit.testing.v1 import AppTest
from kpi import kpis, load, pct_change, previous_period
def tiny():
csv = ("date,channel,orders,revenue,ad_spend,refunds\n"
"2026-09-01,search,4,100,50,0\n"
"2026-09-02,search,6,900,150,90\n")
return load(io.StringIO(csv))
def test_ratio_uses_sums_not_mean_of_daily_ratios():
df = tiny()
k = kpis(df)
assert k["roas"] == 1000 / 200 # 5.0; the mean of daily ROAS (2 and 6) would be 4.0
assert k["aov"] == 1000 / 10 # 100.0; the mean of daily AOV (25 and 150) would be 87.5
assert k["refund_rate"] == 0.09 # the mean of daily rates (0 and 0.1) would be 0.05
def test_previous_period_is_equal_length_and_adjacent():
df = pd.DataFrame({"date": pd.date_range("2026-09-01", "2026-09-30"), "x": 1})
prev = previous_period(df, "2026-09-11", "2026-09-20")
assert prev["date"].min() == pd.Timestamp("2026-09-01")
assert prev["date"].max() == pd.Timestamp("2026-09-10")
assert len(prev) == 10
def test_zero_denominators_do_not_crash():
assert pct_change(5, 0) is None
assert kpis(tiny().iloc[0:0])["roas"] is None
def test_app_renders_five_metrics_and_no_exception():
at = AppTest.from_file("app.py", default_timeout=30).run()
assert not at.exception
assert len(at.metric) == 5
assert at.metric[0].label == "Revenue"
def test_half_selected_date_range_does_not_crash():
import datetime
at = AppTest.from_file("app.py", default_timeout=30).run()
at.date_input(key="range").set_value((datetime.date(2026, 9, 10),)).run()
assert not at.exception
assert at.info[0].value == "Pick an end date."
def test_app_filter_changes_numbers():
at = AppTest.from_file("app.py", default_timeout=30).run()
all_rev = at.metric[0].value
at.multiselect(key="channels").set_value(["email"]).run()
assert not at.exception
assert at.metric[0].value != all_rev
The first test is the one that earns its keep. It uses two days where the average of daily ratios and the ratio of sums give different answers (4.0 versus 5.0 for ROAS), so a version of kpis that averages would fail it.
Real output of pytest -v:
collected 6 items
test_dash.py::test_ratio_uses_sums_not_mean_of_daily_ratios PASSED [ 16%]
test_dash.py::test_previous_period_is_equal_length_and_adjacent PASSED [ 33%]
test_dash.py::test_zero_denominators_do_not_crash PASSED [ 50%]
test_dash.py::test_app_renders_five_metrics_and_no_exception PASSED [ 66%]
test_dash.py::test_half_selected_date_range_does_not_crash PASSED [ 83%]
test_dash.py::test_app_filter_changes_numbers PASSED [100%]
============================== 6 passed in 1.91s ===============================
(Paths trimmed for readability.)
Which two bugs did the tests catch?
Both were in the first draft of app.py, and both are the sort of thing a generated app ships with.
Bug 1: the sample file path depended on the working directory. The first version read "sales.csv". The app tests failed with FileNotFoundError: [Errno 2] No such file or directory: 'sales.csv' because pytest was started from a different directory than the one holding the CSV. It would have failed the same way for anyone running streamlit run from a parent folder. The fix is Path(__file__).parent / "sales.csv", which is why the prompt template tells the model to resolve the path relative to app.py.
Bug 2: a half-selected date range crashed the app. st.date_input with a tuple value returns a one-item tuple while the user has picked a start date but not yet an end date. The first draft unpacked it straight into start, end. I found this by writing a probe that sets the widget to a single date, and it raised ValueError: not enough values to unpack (expected 2, got 1). In a browser that shows up as a red error box mid-click. The fix is the len(picked) != 2 guard, and test_half_selected_date_range_does_not_crash now pins it.
Neither bug appears when you click through the happy path once, which is how most generated dashboards get "tested".
Does the server actually start?
AppTest runs the script without a server, so add one more check that the real thing boots. I started streamlit run app.py --server.headless true --server.port 8599 in the background and requested the health endpoint and the front page:
$ curl -sS -i http://localhost:8599/_stcore/health
HTTP/1.1 200 OK
server: uvicorn
content-type: text/plain; charset=utf-8
cache-control: no-cache
ok
The front page returned HTTP 200. That proves the server starts and serves the shell, nothing more. I did not open it in a browser, so the visual layout, the chart rendering and the file uploader were not checked by eye. Treat that as the manual step you still owe before sharing.
What do the numbers say, and can you trust them?
For September against the previous 30 days (2 August to 31 August) in the invented file, the module gives revenue of 2,880,837 against 2,939,237, orders of 3,534 against 3,644, AOV of 815 against 807, ROAS of 4.36 against 4.36, and a refund rate of 3.65% against 3.95%. Those come from random draws, so don't read anything into them. What matters is that you can reproduce them with a one-line call to kpis and check them against a pivot table in a spreadsheet, which is the sanity check I'd do on real data: compute one KPI by hand in a different tool and make sure it matches to the rupee.
Which KPI definition traps should you decide before the first chart?
Each of these is a choice the dashboard makes for you if you don't make it. Settle them in the spec.
- Percent change on a ratio. The refund rate tile shows a relative change (3.65% against 3.95% reads as roughly an 8 percent drop), which is easy to confuse with the 0.3 percentage-point difference. Pick one convention per tile and label it. For rates, percentage points are usually the clearer choice, and the app above uses relative change everywhere for simplicity.
- Blended versus per-channel ROAS. Blended ROAS (all revenue over all ad spend) includes channels like email that carry almost no ad spend, so it can look healthier than any paid channel. If the decision is where to move budget, show per-channel ROAS in the table and say which one the headline tile uses. The channel filter in the app recomputes the headline for the selected channels, but the table does not show ROAS at all, which is a gap you may want to close.
- Attribution and refund timing. The invented CSV puts each refund on the same row as the sales it relates to, which is the tidy case. Real sources often record refunds on the day they happen, and then a recent month looks better than it will once refunds arrive. Ask where the numbers come from before you compare periods.
- Period alignment. A "previous period of equal length" comparison for a 30-day window in September uses 2 to 31 August, which has a different mix of weekdays than September. If your business is weekly-cyclical, compare against the same weekdays or the same period last year, and say so on the page.
- Small denominators. A channel with 3 orders in the window can swing a ratio wildly. Show the underlying count next to any ratio you plan to act on.
None of these need more code than a label and a sentence, and each one is a place where a reader of the dashboard would otherwise assume something different from what you computed.
How should you use Claude on this, step by step?
- Write the metric definitions and edge cases yourself, in the spec above. Don't let the model choose what ROAS means.
- Ask for the KPI module and tests first, run them, and read the first failing test before touching the UI.
- Ask for the app, keyed widgets included, then run
AppTestagainst it. - When a test fails, paste the error and the test, not the whole app, and ask for the minimal fix. For larger rewrites, plan first; plan mode for large refactors fits here.
- Compute one KPI by hand in a spreadsheet and compare.
- Ask Claude to review the finished code for definitions that differ from the spec. Reviews by a model need their own checks; the LLM-as-judge pitfalls post explains why.
What changes before this goes in front of a team?
This app has no login. Anyone who can reach the port sees the numbers and can upload files. Before you deploy it, decide where it lives and who can reach it.
If you self-host, a small VPS behind a login or a private network is one option. Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. If you go that route, Hostinger VPS is one provider, and the VPS hardening checklist covers what to lock down first; our Hostinger VPS review lists the downsides. Streamlit's own docs list other deployment platforms, and I haven't tested any of them for this post. A hosted option with built-in authentication may be less work than running a server, especially for a single dashboard.
Other things this version doesn't do: it has no handling for missing channel values, no caching strategy for large files beyond st.cache_data on the sample, and no test for the missing-column error path, so treat that branch as unverified. If your CSV is big, load it with explicit dtypes or move to a database.



