ExpenseTracker: Why a Monthly Sheet Beats the Endless Transaction Feed

A small Android app makes a big argument: if you organize money by month first, the UI, database, and charting logic all get simpler, sharper, and more human.

8 min read • View on GitHub • More from 19jenil

A single ledger box sits on a desk, with receipts, coins, and bills sorted into one monthly container while loose transaction slips remain outside it. The scene explains the app’s central idea: time is the primary organizing unit, not an endless feed of spending.
The app treats each month like a bounded container. That choice shapes everything from navigation to balance calculations.
Key Takeaways

The app thinks in months, not transactions

Most expense trackers start with a stream. This one starts with a container. In 19jenil/ExpenseTracker, the core unit is the Monthly Sheet, and that decision changes the whole feel of the product. You are not browsing an infinite ledger. You are closing a loop.

That matters because monthly budgeting is already how many people think about money. Rent, salary, subscriptions, and discretionary spending all reset against a calendar rhythm. The app leans into that rhythm instead of fighting it.

The result is a cleaner mental model. A month has a beginning, a balance, and an end. Expenses live inside it. Income anchors it. The UI can stay focused because the data model already knows what belongs together.

One sheet is a financial container, not a folder

A monthly sheet behaves like a bounded container. Select a month, inspect its income and expenses, and watch the balance fall out of the structure.

The data model follows the same logic. `ExpenseSheet` is not just a folder for rows. It holds the month, the year, the income, and the linked expenses, and it computes balance from those values. That keeps the business rule close to the record it describes.

data class ExpenseSheet(
    val sheetId: String,
    val month: Int,
    val year: Int,
    val income: Double,
    val expenses: List<Expense> = emptyList()
) {
    fun calculateBalance(): Double {
        return income - expenses.sumOf { it.amount }
    }
}

There is also a subtle consequence in how sheet changes work. If the month or year changes, the app treats that as a move into a different container, not a trivial field edit. That is a strong signal: the monthly sheet is the unit of meaning, not just a row in a table.

Why raw SQLite is the point, not a shortcut

This repo could have used Room. It did not. Instead, it uses `SQLiteOpenHelper`, cursors, and explicit foreign keys with `ON DELETE CASCADE`. That is not a missing abstraction. It is a choice to keep the persistence layer visible.

FOREIGN KEY(sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE

That decision makes the system easier to reason about at this scale. You can see exactly when records are inserted, read, updated, and deleted. You can also see the cost: more manual code, more cursor handling, and more responsibility in the ViewModel. For a small, focused app, that trade-off is fair.

DimensionRoom-first trackerThis repo
Persistence styleAnnotations and generated accessManual SQL and cursor reads
Schema controlHigher-level abstractionExplicit table and foreign key control
Learning valueFaster to shipMore transparent mechanics
Complexity fitBetter for larger appsWell suited to a compact utility

The cascade rule is especially important. Delete a sheet, and its expenses vanish with it. That makes the monthly container feel real. The container is not cosmetic. It governs the lifecycle of the data inside it.

Compose handles the surface, ViewModel handles the truth

A hand-built mobile interface sits beside a database cylinder, with a thin line connecting a stored record to a live app card on screen. The image explains how the app writes to SQLite first, then mirrors the change into Compose state so the UI recomposes.
The app writes to disk, then mirrors that change into state. Compose gets the updated truth only after the database operation succeeds.

The UI stack is modern. The state strategy is more old-school. `ExpenseViewModel` writes to the database and then patches the in-memory list so Compose can recompose. That dual update pattern is the glue holding the app together.

dbHelper.updateSheetIncome(sheetId, newIncome)
val index = expenseSheets.indexOfFirst { it.sheetId == sheetId }
if (index != -1) {
    expenseSheets[index] = expenseSheets[index].copy(income = newIncome)
}

That is the right move for this codebase because the database is not just a backend. It is the source of truth, while the mutable state list is the live projection that the UI reads. The code is simple, but the architecture is honest.

QuestionDatabase-first answerState-first answer
What gets updated first?SQLiteCompose state
What is authoritative?Stored rowsIn-memory list
Why it works herePersistence must survive process deathUI needs immediate recomposition
Main riskManual sync bugsState drifting from disk

The graph is custom because the graph is part of the product

The chart in `Graph.kt` is not a decorative extra. It is an extension of the same monthly model. If the app thinks in bounded periods, then the graph should help you move between bounded periods too. A generic charting library would have worked. It would also have hidden the point.

Instead, the repo uses a custom Canvas-based graph with horizontal drag gestures. That gives the developer full control over the shape of the visualization and the interaction model. The graph is not just showing data. It is teaching the user how the app understands time.

pointerInput(Unit) {
    detectHorizontalDragGestures { _, dragAmount ->
        // move across time periods
    }
}

This is where the app feels most coherent. The same month-scoped logic that shapes the database also shapes the visualization. The chart is not bolted on. It belongs to the product model.

What this project gets right, and where it stays small

AspectWhat it gets rightWhat remains limited
Product modelA clear monthly budgeting rhythmNot built for open-ended finance workflows
ArchitectureSimple MVVM with explicit persistenceNo visible dependency injection layer
VisualizationCustom chart matched to the data modelNot a full analytics suite
MaturityFocused and easy to followThin test coverage and lightweight scaffolding

That balance is why the repo is worth reading. It is not trying to be a universal finance platform. It is trying to be a clean monthly tracker, and almost every line supports that goal. The scope is small, but the idea is tight.

If you are comparing it with more mature Android finance apps, the difference is obvious. Bigger projects lean on Room, Hilt, and broader feature sets. This one chooses clarity over breadth. That is a limitation, but also the reason it is readable in one sitting.