Skip to content

06 — dashboards.py: the four Plotly figures

Duration: 15 min Prerequisites: chapter 03 (normalized DataFrame), pip install plotly.

The Plotly figures are pure — they take a DataFrame, return a plotly.graph_objects.Figure. No Streamlit, no IO, no global state.

This lets:

  • Both apps (Ollama + OpenAI) reuse the same figures.
  • A future Jupyter notebook user to call dashboards.fig_monthly(df) directly.
  • Unit tests to compare figure JSON byte-for-byte.

Streamlit only enters the picture in app.py, where st.plotly_chart(fig) does the rendering.

csv-llm-shared/dashboards.py
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
def kpis(df: pd.DataFrame) -> dict:
expenses = df[df["amount"] > 0]
months = expenses["date"].str[:7].nunique() or 1
return {
"total": expenses["amount"].sum(),
"monthly": expenses["amount"].sum() / months,
"avg_tx": expenses["amount"].mean(),
"n_tx": int((df["amount"] > 0).sum()),
}

Four numbers from one DataFrame:

KPIFormulaDemo CSV value
totalsum of expenses$118,667.69
monthlytotal / distinct months$9,888.97
avg_txmean expense$175.80
n_txcount of expenses675

The or 1 guard prevents division by zero when there are zero expense rows.

def fig_monthly(df: pd.DataFrame) -> go.Figure:
expenses = df[df["amount"] > 0].copy()
expenses["month"] = expenses["date"].str[:7]
grouped = expenses.groupby("month", as_index=False)["amount"].sum()
fig = px.bar(grouped, x="month", y="amount",
title="Spending by month",
labels={"month": "Month", "amount": "Total ($)"})
fig.update_layout(bargap=0.2)
return fig

Slicing date[:7] gives "2027-12" — a string that sorts correctly alphabetically. So we don’t need to convert to datetime objects for the x-axis.

Expected output on the demo CSV:

2027-01: $9,173.61
2027-02: $8,544.04
...
2027-11: $16,230.16 ← peak (multiple Travel + Housing)
2027-12: $9,969.31
def fig_categories(df: pd.DataFrame) -> go.Figure:
expenses = df[df["amount"] > 0]
grouped = expenses.groupby("category", as_index=False)["amount"].sum()
return px.pie(grouped, names="category", values="amount",
title="Spending by category", hole=0.3)

hole=0.3 makes a doughnut instead of a pie — same data, easier on the eyes (and you can put a total in the middle if you want, with fig.add_annotation).

Expected: Housing (26% of spend), Travel (14%), Business (12%), Groceries (8%), Education (8%), Health (7%), Dining (7%), Utilities (7%), Entertainment (6%), Transport (4%), other (1%).

def fig_top_merchants(df: pd.DataFrame, n: int = 10) -> go.Figure:
expenses = df[df["amount"] > 0]
top = (expenses.groupby("description", as_index=False)["amount"]
.sum()
.sort_values("amount", ascending=False)
.head(n))
return px.bar(top, x="amount", y="description", orientation="h",
title=f"Top {n} merchants",
labels={"amount": "Total ($)", "description": "Merchant"})

Horizontal bars (orientation="h") for merchants because their names are long. The descending sort is intentional but Plotly will render them with the largest at the top by default with orientation="h" — perfect.

Expected top 5: Rent Payment Willow Street ($6,761), StorageBox Monthly ($5,921), GardenCare Subscription ($5,459), Postbox Rental ($5,243), Queenstown Path Pass ($4,315).

def fig_recurring(df: pd.DataFrame, min_months: int = 3) -> go.Figure:
expenses = df[df["amount"] > 0].copy()
expenses["month"] = expenses["date"].str[:7]
by_merchant = (expenses.groupby("description")["month"]
.nunique()
.reset_index(name="active_months"))
recurring = (by_merchant[by_merchant["active_months"] >= min_months]
.sort_values("active_months", ascending=False)
.head(20))
return px.bar(recurring, x="active_months", y="description", orientation="h",
title=f"Recurring merchants (≥{min_months} active months)")

This is the most interesting figure conceptually:

  • For each merchant, count how many distinct months it appears in.
  • Filter to active_months >= 3 (meaningfully recurring).
  • Take top 20.

For the demo CSV, top recurring merchants:

MerchantActive months
Coastal Spoon Cafe12
Harbor Noodle House12
ClearNet Fibre12
NorthLine Bus Card11
Rent Payment Willow Street8

ClearNet Fibre is a subscription — appears every month, even if small amounts. The chart surfaces these “silent” recurring costs you might want to cancel.

We optimized for insight per chart. Each of the 4 answers a different question:

FigureQuestionDecision it informs
Monthly barWhen did I spend?Identify atypical months
Category doughnutWhat did I spend on?Identify dominant categories
Top merchants barWho did I pay?Identify big-ticket recipients
Recurring merchantsWho do I pay every month?Identify subscriptions to cancel

Adding more (e.g. “spending by day of week”) would dilute the insights without adding decisions. Pareto on dashboards.

Plotly Express picks colors automatically from a sequence. By default that’s D3.category10. To make the four charts feel like a set:

import plotly.io as pio
pio.templates.default = "plotly_white"

at the top of dashboards.py gives every figure a clean white background with light gridlines. Optional — Streamlit’s default works too.

>>> import sys; sys.path.insert(0, "csv-llm-shared")
>>> import ingest, normalize, dashboards
>>> df = ingest.read_csv("csv-llm-shared/data/data1-anonymized.csv")
>>> nf = normalize.normalize(df, profile="data1")
>>> k = dashboards.kpis(nf)
>>> k
{'total': 118667.69, 'monthly': 9888.97, 'avg_tx': 175.8, 'n_tx': 675}
>>> fig = dashboards.fig_monthly(nf)
>>> fig.data[0].y[:3]
array([9173.61, 8544.04, 9150.28])

You can save any figure to PNG with fig.write_image("monthly.png") (requires kaleido installed).

In csv-llm-ollama/app.py (and identically in csv-llm-openai/app.py):

import dashboards
k = dashboards.kpis(df)
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total", f"${k['total']:,.2f}")
c2.metric("Monthly average", f"${k['monthly']:,.2f}")
c3.metric("Avg per tx", f"${k['avg_tx']:,.2f}")
c4.metric("# expense tx", k["n_tx"])
st.plotly_chart(dashboards.fig_monthly(df), use_container_width=True)
st.plotly_chart(dashboards.fig_categories(df), use_container_width=True)
st.plotly_chart(dashboards.fig_top_merchants(df), use_container_width=True)
st.plotly_chart(dashboards.fig_recurring(df), use_container_width=True)

15 lines of UI for the entire Dashboards tab. The library does the heavy lifting.

  • dashboards.py is pure: DataFrame in, Figure out. No Streamlit, no IO.
  • 4 figures cover 90% of “what would I want from a personal-budget app”.
  • Slicing date[:7] is the trick for month-based grouping — strings sort correctly.
  • fig_recurring reveals subscriptions you might forget you have.

Next: smoke_pipeline.py — end-to-end test →