common-cents-tui/CLAUDE.md
Joe Arndt ac93e6074b init
2026-05-06 18:25:17 -05:00

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/) — covers money, 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 after pip 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.pyCommonCentsApp, takes db_path, pushes the main menu, and exposes switch_db / reset_db_to_default for the Options screen.
  • src/common_cents/config.py — persisted user config (db_path key) 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.py provides an AppScreen mixin with a typed self.db accessor.
  • src/common_cents/widgets/ — reusable widgets (CenteredFooter, AutocompleteInput).
  • src/common_cents/db.py — SQLite wrapper. Amounts are stored as integer cents. PRAGMA user_version drives 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 the format_budget_cell colour-graded $used / $budget (%) renderable.
  • src/common_cents/category_tree.pyParent: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:

  1. --db PATH — CLI flag.
  2. $COMMON_CENTS_DB — environment variable.
  3. The path persisted by the in-app Options screen (o from the main menu), stored in $XDG_CONFIG_HOME/common-cents/config.json (fallback ~/.config/common-cents/config.json) under the db_path key. The Options screen calls app.switch_db, which opens the new DB first and closes the previous connection only on success — a failed switch leaves the old connection intact.
  4. ./common-cents.db — legacy CWD fallback. Used with a one-line stderr migration hint, only if the default location does not yet exist.
  5. 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.pyCommonCentsApp(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.pyAppScreen[T] is a thin Screen[T] subclass exposing self.db (typed as Database) so screens never have to reach for self.app.db and re-cast. New screens should inherit from AppScreen rather than Screen directly.
  • main_menu.py — dashboard with monthly summary, hierarchical category breakdown (with prev-month delta and format_budget_cell cell), and recent spending. Bindings: s/m/r/i/x/o/q for Spending / Metadata / Reports / Import / Export / Options / Quit. The categories panel shares the trie from common_cents.category_tree with the reports screen, so subcategory spending rolls up into parents (e.g. Food:Restaurants contributes to Food's total + budget). The panel is wrapped in a VerticalScroll and 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_resume re-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 / max on 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, b to bulk-edit the selection, escape to 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_spending re-resolves names. Invalid filter inputs paint themselves red via :invalid but don't clobber the current view.
  • _spending_form.py — modal form for add/edit. Uses AutocompleteInput for category, merchant, and tags with inline creation.
  • _spending_bulk_form.pyBulkEditScreen applies 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 reading get_spending_by_id and overlaying. Tags replace the existing set on each record. Magic [clear] token in Merchant or Notes blanks those columns. Returns the count actually updated; 0 means cancelled / nothing applicable.
  • metadata.pyMetadataScreen is a three-column layout (Categories | Merchants | Tags), each column a labeled DataTable. Categories carry an optional monthly budget rendered in a second column. The currently focused column determines what e (edit) and d (delete) operate on. a (add) opens MetadataAddModal with a kind selector defaulting to the focused column; the modal also exposes a budget input that hides itself when kind ≠ category. c/m/t focus the corresponding column (hidden from the footer); tab/shift-tab rotates focus. MetadataFormModal (name-only) is used for edit-of-merchant/tag and reused by _spending_form.py for 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 , Max on category totals). For Custom, two Select dropdowns of MMM YYYY months 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 a Parent:Child[:Grandchild...] convention with arbitrary depth; the trie is built by common_cents.category_tree.build_category_tree from the flat path strings (intermediate ancestors are derived — their self_total is 0 unless the user actually recorded spending against the exact path), and _render_nodes walks it recursively. When an internal node has nonzero self_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 with DirectoryTree file 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 via DirectoryTree (directories and existing CSVs shown for orientation; clicking a CSV prefills the filename to overwrite it). Default filename is common-cents-YYYY-MM-DD.csv. Output round-trips through parse_csv (header DATE,CENTS,CATEGORY,MERCHANT,NOTES,TAGS, multi-tag values comma-joined and CSV-quoted by csv.writer). Tags within a row are sorted alphabetically for deterministic output. Existing-file overwrites require a warning confirmation.
  • options.pyOptionsScreen switches the active database. Filtered _DBDirectoryTree shows only directories and .db/.sqlite files; clicking a file or typing a path into the bottom input prompts a ConfirmModal and then calls app.switch_db. r resets 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 — reusable ConfirmModal with optional detail lines, configurable button labels, and success/warning border variants.

Widgets (src/common_cents/widgets/)

  • footer.pyCenteredFooter(Footer) centers itself by computing (screen_width - footer_width) // 2 as a left margin on mount and resize. Textual CSS margin: auto is not supported — centering must be done in Python.
  • autocomplete.pyAutocompleteInput composite widget: an Input with a dropdown OptionList. 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_name because the CSV importer/exporter use , as the tag delimiter.
  • spendingdate (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 kind column ('category' | 'merchant' | 'tag') so reads can filter without joining metadata. Cascades deletes from both spending and metadata. Cardinality is schema-enforced: partial unique indexes WHERE kind = 'category' / WHERE kind = 'merchant' cap each spending row at one category and at most one merchant; tags are unrestricted. db.py write paths route through _link_metadata; reads use scalar subqueries filtered on sm.kind to 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 whose metadata.kind = 'category'; the schema doesn't enforce that constraint (it'd require triggers), so callers must respect it. get_metadata returns budget_cents as 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.money for parsing and formatting: parse_dollars (user input → cents, returns None for blank/invalid, string-based — no float), parse_cents_csv (CSV CENTS column → cents, raises on bad input), format_cents (display, $1,234.56), format_cents_input (bare value for editable Input, 1234.56), format_budget_cell ($used / $budget (N%) Text coloured green/yellow/red by % consumed). Don't reintroduce per-screen _fmt/_parse_dollars helpers.
  • Vim navigation: all DataTable and DirectoryTree screens bind j/k/g/G (hidden from footer) delegating to the focused widget's built-in cursor actions.
  • Footer: always use CenteredFooter (not the built-in Footer) in new screens.
  • Rich tables in Static: use rich.box.SQUARE for spreadsheet-style grids. Stack a title above a table using rich.console.Group(Text(..., justify="center"), table).
  • Textual CSS auto margins: not supported — compute margins in Python via widget.styles.margin.