Benjamin ThaiSoftware Engineer · ML
← Projects
Web2025Complete

Expense Tracker

A Flask web app to log spending, categorize it, and visualize trends with auto-generated bar and line charts.

GitHub ↗
PythonFlaskPandasMatplotlib
01THE PROBLEM

I wanted the chart, not the spreadsheet ritual.

Tracking spending in a spreadsheet works right up until you want to see it. Then it's a pivot table, or a chart you rebuild by hand every time the range grows, or an export into something else. The data was never the hard part. Keeping the view of it current was.

So the target was narrow on purpose: type three fields, and get back a table and two charts computed from every row you have ever entered, with no build step, no account, and no database to stand up first.

02SCOPE

What it had to do, and what it deliberately doesn't.

Log

A date, one of seven categories, and an amount. The form is the only way data gets in, and every field is required. There is no import, no receipt parsing, and no recurring entries.

Show

Every expense in one table, in insertion order, each row with a delete button. No paging, sorting, or filtering, because at a few hundred rows none of those earn their complexity.

Summarize

Two charts redrawn from the full dataset on every page load: totals per category as bars, totals per month as a line. Nothing is cached, so nothing can be stale.

Single-user and local by design: one Python file, one CSV, one flask run. There is no auth, and the app assumes exactly one person is using it at a time.

03THE APPROACH

One file, one request cycle, one source of truth.

Every request reads the CSV into a pandas DataFrame, does its work, and writes the whole file back if anything changed. No state survives between requests, which means the file on disk is always the truth. You can edit it in a spreadsheet mid-session and the next page load reflects it.

The charts follow the same rule. Rather than storing generated images, plot_expenses groups the DataFrame, draws two matplotlib figures, writes them into an in-memory buffer, and hands back base64 strings that get inlined into the HTML as data URIs. Nothing touches a static directory, so there are no orphaned PNGs and no invalidation logic. The images cannot disagree with the data, because they are built from it on the spot.

Matplotlib defaults to a GUI backend, which falls over in a web process the moment a figure is created off the main thread. Setting matplotlib.use("Agg") is the one line that makes any of this work in a server at all, and it has to run before pyplot is imported rather than after.

04HOW IT WORKS

What happens between the submit and the redraw.

01 · POST

The row is appended, the file is rewritten

The form posts to the same route that renders the page. The new row takes the highest existing ID plus one, gets concatenated onto the DataFrame, and the whole CSV is written back out.

02 · REDIRECT

Post/Redirect/Get, so a refresh is harmless

The POST answers with a redirect rather than HTML. Without it, refreshing after a submit would re-post the form and log the same expense a second time.

03 · GET

Read, group, draw, inline

The GET reloads the CSV, groups it two ways with pandas, renders both figures to PNG bytes in memory, and base64-inlines them into one HTML response carrying the table and both charts.

05INTERFACE DECISIONS

Choices behind a deliberately small app.

A CSV instead of SQLite
SQLite would have been the same amount of setup and given real types and atomic writes. The CSV won because the data stays legible without the app — open it in Excel, diff it in git, fix a typo in a text editor. For a personal ledger of a few hundred rows that was worth more than transactional safety.
Charts as base64 data URIs, not static files
Writing PNGs to disk means naming them, serving them, and invalidating them when the data changes. Inlining makes the response self-contained and the staleness question disappear, at the cost of a heavier HTML payload on every load.
Server-rendered matplotlib instead of a JS charting library
The grouping already happens in pandas inside the request. Once the DataFrame is there, drawing the figure is four more lines, and the browser gets a finished image rather than a data payload plus a library plus a client-side render.
The entire UI in one render_template_string
One file means the app is a single thing to read and copy. It stops paying off the moment there's a second page, at which point the template belongs in templates/ and this decision should be reversed rather than defended.
06SCREENS
▶expensetracker.mp4
Six months of history, three expenses logged live, and one deleted. Every submit is a full round trip, so the charts at the end were drawn by the server after the redirect, not updated in the browser.
The whole app on one page: the form at the top, the history under it, the charts at the bottom. There is no second screen and no navigation, because there is nothing else to navigate to.
The whole app on one page: the form at the top, the history under it, the charts at the bottom. There is no second screen and no navigation, because there is nothing else to navigate to.
Both charts are matplotlib PNGs, grouped by pandas, drawn in memory, and base64-inlined into the page on every request. Spend per category on the left, spend per month on the right.
Both charts are matplotlib PNGs, grouped by pandas, drawn in memory, and base64-inlined into the page on every request. Spend per category on the left, spend per month on the right.
First run: no CSV yet, so the app writes an empty one and renders the table with no rows and no charts
First run: no CSV yet, so the app writes an empty one and renders the table with no rows and no charts
Date, category, amount: three fields and a submit, which is the entire input surface
Date, category, amount: three fields and a submit, which is the entire input surface
07TRADEOFFS

What I chose, and what it cost.

Chose

Regenerating both charts on every request

Cost

Two matplotlib figures are drawn, encoded, and thrown away on every page load, including loads that changed nothing. Imperceptible at a few hundred rows, and the wrong shape at a few hundred thousand. The fix is to cache against a hash of the data, which this doesn't do because it has never needed to.

Chose

Delete as a GET link with a JavaScript confirm

Cost

It's one anchor tag and one onclick, which is why it exists in that form, but a GET that mutates state is a real bug rather than a shortcut. Anything that fetches links on the user's behalf could remove a row without a click, and the confirm is browser-side only. It should be a POST with a form button.

Chose

Rewriting the whole CSV on every mutation

Cost

Two writes racing each other lose data, and a crash mid-write truncates the file with no backup. For one person on localhost that risk never fires; it is also exactly what makes a CSV the wrong answer the moment a second user exists.

08WHAT'S NEXT

Where I'd take it.

→

Move deletes to a POST, and add an edit path. A mistyped amount currently has to be deleted and re-entered, and the delete itself is a GET. Both are the same fix: treat mutations as form submissions.

→

A date-range filter driving both the table and the charts. Every view is all-time right now, which is the least useful window for the question the app exists to answer.

→

Swap the CSV for SQLite behind the same two functions. load_data and save_data are the only places that touch storage, so the change stays contained, and it buys real types, atomic writes, and room for more than one user.