06 — dashboards.py: the four Plotly figures
Duration: 15 min Prerequisites: chapter 03 (normalized DataFrame),
pip install plotly.
Why dashboards live in csv-llm-shared
Section titled “Why dashboards live in csv-llm-shared”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.
The KPIs helper
Section titled “The KPIs helper”import pandas as pdimport plotly.express as pximport 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:
| KPI | Formula | Demo CSV value |
|---|---|---|
total | sum of expenses | $118,667.69 |
monthly | total / distinct months | $9,888.97 |
avg_tx | mean expense | $175.80 |
n_tx | count of expenses | 675 |
The or 1 guard prevents division by zero when there are zero expense rows.
Figure 1 — Monthly bar
Section titled “Figure 1 — Monthly bar”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 figSlicing 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.612027-02: $8,544.04...2027-11: $16,230.16 ← peak (multiple Travel + Housing)2027-12: $9,969.31Figure 2 — Category doughnut
Section titled “Figure 2 — Category doughnut”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%).
Figure 3 — Top 10 merchants
Section titled “Figure 3 — Top 10 merchants”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).
Figure 4 — Recurring merchants
Section titled “Figure 4 — Recurring merchants”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:
| Merchant | Active months |
|---|---|
| Coastal Spoon Cafe | 12 |
| Harbor Noodle House | 12 |
| ClearNet Fibre | 12 |
| NorthLine Bus Card | 11 |
| Rent Payment Willow Street | 8 |
ClearNet Fibre is a subscription — appears every month, even if small amounts. The chart surfaces these “silent” recurring costs you might want to cancel.
Why these 4 and not 7?
Section titled “Why these 4 and not 7?”We optimized for insight per chart. Each of the 4 answers a different question:
| Figure | Question | Decision it informs |
|---|---|---|
| Monthly bar | When did I spend? | Identify atypical months |
| Category doughnut | What did I spend on? | Identify dominant categories |
| Top merchants bar | Who did I pay? | Identify big-ticket recipients |
| Recurring merchants | Who 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.
Visual cohesion
Section titled “Visual cohesion”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 piopio.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.
Live test
Section titled “Live test”>>> 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).
Where Streamlit comes in (briefly)
Section titled “Where Streamlit comes in (briefly)”In csv-llm-ollama/app.py (and identically in csv-llm-openai/app.py):
import dashboardsk = 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.
Takeaways
Section titled “Takeaways”dashboards.pyis 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_recurringreveals subscriptions you might forget you have.