# 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](https://textual.textualize.io/), 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` ```bash 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`, 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.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: 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: ```bash 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 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.py`** — `BulkEditScreen` 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.py`** — `MetadataScreen` 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.py`** — `OptionsScreen` 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.py`** — `CenteredFooter(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.py`** — `AutocompleteInput` 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. - **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 `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`.