16 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Common Cents is a terminal TUI spending tracker built with Python 3.13, Textual, and SQLite. The application is fully implemented and running.
Development Setup
- Python 3.13, managed by uv
- Formatter: Black (line length 88, target py313)
- Linter: ruff (config in
pyproject.toml) - Tests: pytest (in
tests/) — coversmoney,db,csv_import/csv_export,category_tree, the bulk-edit data path, the persisted-config layer, and the reports date-range helpers. Pure-Python, no Textual rendering required. - Database: SQLite, initialized from
src/common_cents/schema.sql(packaged alongside the module so it's available afterpip install) - Sample data:
sample_spending.csv
uv sync --all-extras # create .venv and install all deps including dev
uv run common-cents # run the app
uv run pytest # run tests (fast — pure-Python, no UI)
uv run ruff check . # lint
uv run black . # format
uv build # build wheel + sdist into dist/
Both ruff check and pytest must pass before merging. Ruff is configured to ignore PLC0415 (we use nested imports to avoid circular screen imports) and PLR0913 (DB methods take many parameters by design). See [tool.ruff.lint] in pyproject.toml.
Project layout
src/common_cents/__main__.py— CLI entry point; resolves the DB path.src/common_cents/app.py—CommonCentsApp, takesdb_path, pushes the main menu, and exposesswitch_db/reset_db_to_defaultfor the Options screen.src/common_cents/config.py— persisted user config (db_pathkey) under$XDG_CONFIG_HOME/common-cents/config.json.src/common_cents/screens/— one module per screen (main menu, spending, bulk-edit form, metadata, reports, CSV import, CSV export, options, modals)._base.pyprovides anAppScreenmixin with a typedself.dbaccessor.src/common_cents/widgets/— reusable widgets (CenteredFooter,AutocompleteInput).src/common_cents/db.py— SQLite wrapper. Amounts are stored as integer cents.PRAGMA user_versiondrives schema migrations on startup.src/common_cents/csv_import.py/csv_export.py— CSV reader and writer.src/common_cents/money.py— money parsing/formatting helpers (string-based, no float), plus theformat_budget_cellcolour-graded$used / $budget (%)renderable.src/common_cents/category_tree.py—Parent:Child[:Grandchild...]trie shared by the reports screen and the dashboard's top-categories panel.src/common_cents/schema.sql— database schema (four tables:metadata,spending,spending_metadata,budget); packaged as data alongside the module.
Database location and resolution
The DB lives at $XDG_DATA_HOME/common-cents/common-cents.db (fallback ~/.local/share/common-cents/common-cents.db) by default.
Resolution order in _resolve_db_path (__main__.py), highest priority first:
--db PATH— CLI flag.$COMMON_CENTS_DB— environment variable.- The path persisted by the in-app Options screen (
ofrom the main menu), stored in$XDG_CONFIG_HOME/common-cents/config.json(fallback~/.config/common-cents/config.json) under thedb_pathkey. The Options screen callsapp.switch_db, which opens the new DB first and closes the previous connection only on success — a failed switch leaves the old connection intact. ./common-cents.db— legacy CWD fallback. Used with a one-line stderr migration hint, only if the default location does not yet exist.- The default path above.
To recreate the database from scratch:
DB="${COMMON_CENTS_DB:-${XDG_DATA_HOME:-$HOME/.local/share}/common-cents/common-cents.db}"
rm -f "$DB" && mkdir -p "$(dirname "$DB")" && sqlite3 "$DB" < src/common_cents/schema.sql
Architecture
Entry point
app.py — CommonCentsApp(App). Sets self.theme = "atom-one-dark" in __init__ (class-level THEME is silently ignored in Textual 8). Overrides get_key_display to uppercase all footer key labels. Exposes switch_db(path, persist=True) and reset_db_to_default() so the Options screen can swap the live Database connection without restarting the app — both methods open the new connection first and only close the previous one on success, so a failed switch leaves the old DB intact.
Screens (src/common_cents/screens/)
_base.py—AppScreen[T]is a thinScreen[T]subclass exposingself.db(typed asDatabase) so screens never have to reach forself.app.dband re-cast. New screens should inherit fromAppScreenrather thanScreendirectly.main_menu.py— dashboard with monthly summary, hierarchical category breakdown (with prev-month delta andformat_budget_cellcell), and recent spending. Bindings:s/m/r/i/x/o/qfor Spending / Metadata / Reports / Import / Export / Options / Quit. The categories panel shares the trie fromcommon_cents.category_treewith the reports screen, so subcategory spending rolls up into parents (e.g.Food:Restaurantscontributes toFood's total + budget). The panel is wrapped in aVerticalScrolland rows are filtered to those with this-month spending, prev-month spending, or a configured budget — prev-month-only branches still roll up into ancestors' delta but are hidden at render so they don't clutter the list. Title shows the active DB path;on_screen_resumere-renders so a DB switch is reflected immediately.spending.py— full CRUD for spending records with a two-row filter panel (date from / date to / min/ maxon row 1; category / merchant / tag / notes substring + Clear button on row 2). Selection state lives in_selected_ids: set[int]and is keyed by spending id, so it survives filter changes. Bindings:a/e/d/u(add/edit/delete/undo last delete),space/*to toggle a row / all visible rows,bto bulk-edit the selection,escapeto clear the selection or pop. The undo buffer (_undo) snapshots category/merchant/tag names (not ids) so it works even if metadata was renamed/deleted in the meantime —db.add_spendingre-resolves names. Invalid filter inputs paint themselves red via:invalidbut don't clobber the current view._spending_form.py— modal form for add/edit. UsesAutocompleteInputfor category, merchant, and tags with inline creation._spending_bulk_form.py—BulkEditScreenapplies a per-field diff to a list of spending ids. Each row is a checkbox + field; typing in a field auto-ticks its checkbox so the user doesn't have to remember the dual-step. Only ticked fields are written; everything else is preserved per row by readingget_spending_by_idand overlaying. Tags replace the existing set on each record. Magic[clear]token in Merchant or Notes blanks those columns. Returns the count actually updated;0means cancelled / nothing applicable.metadata.py—MetadataScreenis a three-column layout (Categories | Merchants | Tags), each column a labeledDataTable. Categories carry an optional monthly budget rendered in a second column. The currently focused column determines whate(edit) andd(delete) operate on.a(add) opensMetadataAddModalwith a kind selector defaulting to the focused column; the modal also exposes a budget input that hides itself when kind ≠ category.c/m/tfocus the corresponding column (hidden from the footer);tab/shift-tabrotates focus.MetadataFormModal(name-only) is used for edit-of-merchant/tag and reused by_spending_form.pyfor inline tag creation.CategoryFormModal(name + budget) is used for category edit; a blank budget clears any existing one. Delete is blocked at the app layer if the row is referenced by spending. Names with usage_count = 0 render in yellow as a "you can safely delete me" hint.reports.py— category breakdown report with period selector (This Month / Last Month / YTD / Last 12 Months / Custom) and an always-visible filter row (Category substring, Tag substring, Min, Maxon category totals). For Custom, twoSelectdropdowns ofMMM YYYYmonths span from the earliest spending row (or the trailing 12 months if the DB is empty) to the current month — invalid swap-order is auto-corrected. The "Clear" button resets the four filter inputs. Categories follow aParent:Child[:Grandchild...]convention with arbitrary depth; the trie is built bycommon_cents.category_tree.build_category_treefrom the flat path strings (intermediate ancestors are derived — theirself_totalis0unless the user actually recorded spending against the exact path), and_render_nodeswalks it recursively. When an internal node has nonzeroself_total(the user recorded spending against a parent path and deeper paths underneath it), a synthetic(direct)child leaf is rendered so the displayed totals reconcile. Monthly budgets are scaled to the displayed period by_months_in_range(e.g. a $500/month budget becomes $2500 for a 5-month YTD window).import_csv.py— CSV import withDirectoryTreefile browser, inline preview, and post-import confirmation dialog. Rows whose(date, cents, category, merchant, notes, tags)already match an existing spending record (or an earlier row in the same batch) are flagged in the warnings modal and skipped during import.export_csv.py— CSV export to a directory chosen viaDirectoryTree(directories and existing CSVs shown for orientation; clicking a CSV prefills the filename to overwrite it). Default filename iscommon-cents-YYYY-MM-DD.csv. Output round-trips throughparse_csv(headerDATE,CENTS,CATEGORY,MERCHANT,NOTES,TAGS, multi-tag values comma-joined and CSV-quoted bycsv.writer). Tags within a row are sorted alphabetically for deterministic output. Existing-file overwrites require a warning confirmation.options.py—OptionsScreenswitches the active database. Filtered_DBDirectoryTreeshows only directories and.db/.sqlitefiles; clicking a file or typing a path into the bottom input prompts aConfirmModaland then callsapp.switch_db.rresets to the platform-default DB and clears the persisted config override. The currently active path is shown at the top relative to~when possible._confirm.py— reusableConfirmModalwith optional detail lines, configurable button labels, and success/warning border variants.
Widgets (src/common_cents/widgets/)
footer.py—CenteredFooter(Footer)centers itself by computing(screen_width - footer_width) // 2as a left margin on mount and resize. Textual CSSmargin: autois not supported — centering must be done in Python.autocomplete.py—AutocompleteInputcomposite widget: anInputwith a dropdownOptionList. Shows full sorted list on focus or when input is cleared; filters as you type.
Data layer (db.py)
Single Database class wrapping a sqlite3.Connection. The connection opens (and migrations run) eagerly in Database.__init__, so self.conn is always populated and methods can use it without None-checks. All amounts are stored and passed as integer cents. The class is also a context manager (__enter__/__exit__) — useful in tests. default_db_path() and the budget helpers (set_budget/clear_budget/get_budget) live alongside the Database class.
Persisted config (config.py)
load_config / save_config read and write a JSON file at $XDG_CONFIG_HOME/common-cents/config.json (fallback ~/.config/common-cents/config.json). The only key currently used is db_path; thin wrappers get_active_db_path / set_active_db_path / clear_active_db_path are what the Options screen and __main__.py consume. Malformed or missing files return {} so a corrupt config never blocks startup.
CSV import (csv_import.py)
parse_csv(path) -> ParseResult. Required columns: DATE, CENTS, CATEGORY, MERCHANT. Optional: NOTES, TAGS. Tags split by , (multi-tag values must be quoted in the CSV, e.g. "work,personal", since the CSV itself is comma-delimited). Cents may be comma-formatted ("14,901"). Returns ParseResult with rows, warnings, and errors.
Database Schema
Four tables (src/common_cents/schema.sql):
- metadata — unified table for categories, merchants, and tags. Columns:
id,name(COLLATE NOCASE),kind(CHECK in'category'/'merchant'/'tag'),created_at.UNIQUE(kind, name COLLATE NOCASE). Deletion is blocked at the app layer if the row is referenced by spending. Names containing,are rejected by_validate_namebecause the CSV importer/exporter use,as the tag delimiter. - spending —
date(DATE),cents(INTEGER > 0),notes(optional),created_at,updated_at. No FK columns — all metadata associations live in the join table below. - spending_metadata — single join table linking spending rows to any metadata row regardless of kind. Carries a denormalised
kindcolumn ('category' | 'merchant' | 'tag') so reads can filter without joiningmetadata. Cascades deletes from bothspendingandmetadata. Cardinality is schema-enforced: partial unique indexesWHERE kind = 'category'/WHERE kind = 'merchant'cap each spending row at one category and at most one merchant; tags are unrestricted.db.pywrite paths route through_link_metadata; reads use scalar subqueries filtered onsm.kindto surface category/merchant/tags per spending row, avoiding the cross-product blowup of joining the table three times. - budget — optional per-category monthly cap.
metadata_id PRIMARY KEY REFERENCES metadata(id) ON DELETE CASCADE,monthly_cents INTEGER NOT NULL CHECK (monthly_cents > 0). Application code only writes rows whosemetadata.kind = 'category'; the schema doesn't enforce that constraint (it'd require triggers), so callers must respect it.get_metadatareturnsbudget_centsas a scalar subquery so the metadata screen can render the column without a separate query.
Indexes: metadata(kind, name), spending(date), spending_metadata(metadata_id), spending_metadata(spending_id, kind), plus the two partial unique indexes above.
Schema versioning
PRAGMA user_version tracks schema state. Fresh DBs are initialised at the current version (the value schema.sql sets at the bottom — currently 2); older DBs are migrated in Database._migrate, which chains _migrate_to_v1 (added the kind column to spending_metadata plus the cardinality unique indexes) and _migrate_to_v2 (added the budget table). Bumping the schema requires updating SCHEMA_VERSION in db.py, adding a _migrate_to_vN method, extending the chain in _migrate, and ensuring the new schema.sql ends with PRAGMA user_version = N. A DB with user_version higher than SCHEMA_VERSION raises rather than silently downgrading.
Key Conventions
- Amounts: always integer cents internally. Use
common_cents.moneyfor parsing and formatting:parse_dollars(user input → cents, returnsNonefor blank/invalid, string-based — no float),parse_cents_csv(CSVCENTScolumn → cents, raises on bad input),format_cents(display,$1,234.56),format_cents_input(bare value for editableInput,1234.56),format_budget_cell($used / $budget (N%)Text coloured green/yellow/red by % consumed). Don't reintroduce per-screen_fmt/_parse_dollarshelpers. - Vim navigation: all
DataTableandDirectoryTreescreens bindj/k/g/G(hidden from footer) delegating to the focused widget's built-in cursor actions. - Footer: always use
CenteredFooter(not the built-inFooter) in new screens. - Rich tables in Static: use
rich.box.SQUAREfor spreadsheet-style grids. Stack a title above a table usingrich.console.Group(Text(..., justify="center"), table). - Textual CSS
automargins: not supported — compute margins in Python viawidget.styles.margin.