commit 390b9ffe564f59f09ccc61a047c31fe5f1801bda Author: archeious Date: Fri Apr 17 12:13:33 2026 -0600 docs: initial wiki standard set (Home, Architecture, DevelopmentGuide, Operations, Roadmap) diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..d4ead0f --- /dev/null +++ b/Architecture.md @@ -0,0 +1,146 @@ +# Architecture + +Small FastAPI app rendering Jinja2 templates with HTMX-driven partial +updates. SQLite is the only storage. SQLAlchemy 2.x is the ORM; Alembic +manages schema. + +## Modules + +``` +src/quartermaster/ + main.py FastAPI app factory + routes.py Budget configuration HTTP handlers + routes_month.py Monthly view HTTP handlers + service.py Budget queries, totals, target logic, zero-amount + month_service.py Snapshot, deviation, per-month CRUD, month zero + models.py SQLAlchemy models and Section enum + db.py Engine, session, PRAGMA foreign_keys=ON + config.py DB URL resolution + templates/ Jinja2 templates (base, index, month, partials) + static/app.css All styling +``` + +## Sections + +``` +Section enum (budget + month): + income, fixed_bill, debt_minimum, food, subscription, other +``` + +Primary Debt Target is NOT a section. It is a singleton pointer that +selects one of the Debt Minimums rows as the current focus. Excluded +from the zero-amount math since it has no total of its own. + +## Budget data model + +``` +entry + id, section, name, amount, created_at, updated_at + +debt_target (singleton, id = 1) + id, debt_minimum_id FK entry(id) ON DELETE SET NULL, updated_at +``` + +Deleting the entry a target points at nulls the pointer; no cascade to +the entry itself. + +## Month snapshot data model + +``` +month + id, year_month UNIQUE, created_at + +month_entry + id, month_id FK month(id) ON DELETE CASCADE, + section, name, planned, applied, + origin_name NULL, origin_planned NULL, + source_entry_id FK entry(id) ON DELETE SET NULL, + created_at, updated_at + +month_debt_target (singleton per month) + month_id PK FK month(id) ON DELETE CASCADE, + month_entry_id FK month_entry(id) ON DELETE SET NULL, + updated_at +``` + +Creating a month copies every budget entry into `month_entry` and +preserves `origin_name` / `origin_planned`. `source_entry_id` links +back to the budget but is informational only; the snapshot is +self-contained so the month keeps working even after the referenced +budget row is deleted. + +### Deviation states + +Computed from `(name, planned)` vs `(origin_name, origin_planned)`: + +* `unchanged` — both match. No tag. +* `edited` — name or planned differs. Orange tint + "modified" tag. +* `new_in_month` — no origin (row added after snapshot). Blue tint + + "new this month" tag. + +### Per-month debt target + +Copied from the budget's current debt target at creation time, resolved +through `source_entry_id`. Fully editable per month. + +## Zero-amount calculation + +`sum(income.amount) - sum(non_income.amount)` with +`debt_target` excluded. On the budget page there is one value; on the +month page there are two (Planned and Applied), each computed over the +relevant column. + +Colour tone: + +* zero → green (`tone-zero`) +* positive → amber (`tone-positive`): unassigned income +* negative → red (`tone-negative`): over-budget + +## Routes + +### Budget (`src/quartermaster/routes.py`) + +``` +GET / index page +POST /sections/{section}/entries add entry +DELETE /entries/{entry_id} remove entry +POST /debt-target set / clear debt target +``` + +### Month (`src/quartermaster/routes_month.py`) + +``` +GET /month/{year_month} view or create-flow page +POST /month/{year_month}/create snapshot the budget +POST /month/{year_month}/sections/{section}/entries add month entry +POST /month/{year_month}/entries/{entry_id} update (name / planned / applied) +DELETE /month/{year_month}/entries/{entry_id} remove month entry +POST /month/{year_month}/target set / clear per-month target +``` + +All mutation routes return the section partial plus OOB swaps for: + +* The debt target card when a Debt Minimums row was added, edited, or + deleted +* The Zero Amount widget on every entry change + +OOB swap is cheaper than re-rendering the full page and keeps totals +in sync without a reload. + +## HTMX conventions + +* Each month entry row carries three inline inputs (`name`, `planned`, + `applied`) with `hx-post` and `hx-trigger="change"`. Each input + sends only its own field; the server accepts any subset. +* Section partials swap via `hx-target="#section-{section}"` + + `hx-swap="outerHTML"`. +* OOB swaps use `hx-swap-oob="outerHTML"` on the top-level element of + the partial so the same id can be re-rendered anywhere in the page. + +## Testing + +* Service tests hit an in-memory SQLite engine directly. +* Route tests use FastAPI's `TestClient` with a dependency override to + share the same in-memory engine across threads via `StaticPool`. +* Backup script tests shell out and assert on filenames and sqlite + round-trips. diff --git a/DevelopmentGuide.md b/DevelopmentGuide.md new file mode 100644 index 0000000..c15cc45 --- /dev/null +++ b/DevelopmentGuide.md @@ -0,0 +1,148 @@ +# Development Guide + +## Prerequisites + +* Python 3.12+ +* [uv](https://github.com/astral-sh/uv) for dependency management +* SQLite (the library, not the CLI; the backup script uses Python's + sqlite3 module so no `sqlite3` binary is required) + +## Setup + +```sh +git clone ssh://git@forgejo.labbity.unbiasedgeek.com:2222/archeious/quartermaster.git +cd quartermaster +uv sync +uv run alembic upgrade head +``` + +The migration creates `./quartermaster.db` on first run. Override with +`QUARTERMASTER_DB_URL=sqlite:///path/to/other.db` if you want the data +file elsewhere. + +## Run the app + +```sh +uv run uvicorn quartermaster.main:app --reload +``` + +Open http://127.0.0.1:8000. + +## Run tests + +```sh +uv run pytest +``` + +Tests use an in-memory SQLite engine; no separate migration step +needed. + +## Developing against a throwaway DB + +Dev churn that needs a clean schema (autogenerating a new migration, +loading a scratch dataset) should target a throwaway path, never +`./quartermaster.db`. See [Operations](Operations) for why. + +```sh +QUARTERMASTER_DB_URL=sqlite:////tmp/qm-dev.db \ +QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups \ + uv run alembic upgrade head + +QUARTERMASTER_DB_URL=sqlite:////tmp/qm-dev.db \ +QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups \ + uv run uvicorn quartermaster.main:app --reload +``` + +## Project layout + +``` +src/quartermaster/ + main.py FastAPI app factory + routes.py Budget configuration handlers + routes_month.py Monthly view handlers + service.py Budget service: entries, target, zero-amount + month_service.py Month service: snapshot, deviation, zero-amount + models.py SQLAlchemy models, Section enum + db.py Engine, session, PRAGMA foreign_keys=ON + config.py DB URL resolution + templates/ + base.html + index.html Budget config page + month.html Month view page + month_create.html Create-flow for missing month + partials/ + section.html Budget section card + target_card.html Budget debt target card + budget_zero.html Budget Zero Amount widget + month_section.html Month section card + month_target.html Month debt target card + month_zero.html Month Zero Amount widget (Planned/Applied) + month_nav.html Prev / next / picker / back-to-config + static/app.css +alembic/ + env.py Includes backup-db.sh hook before migrations + versions/ Migration files +scripts/ + backup-db.sh sqlite3.Connection.backup wrapper +tests/ + test_service.py + test_routes.py + test_month_service.py + test_month_routes.py + test_zero_amount.py + test_backup_script.py +``` + +## Conventions + +### Issue-driven work + +Every change ties to a Forgejo issue. Branch naming: + +``` +feat/- new feature +fix/- bug fix +refactor/- internal change +chore/- tooling, deps, docs +``` + +### Atomic commits + +Each commit is one logical change. Typical PRs in this repo have 3-5 +commits grouped by layer (data model, service, routes + templates, +tests, docs). Commit message subject under 72 chars, body under 80, +trailers include `Refs #` and the `Co-Authored-By` line for +Claude sessions. + +### Merge flow + +PRs merge via the Forgejo API (or web UI merge button), NOT `git merge` +locally. After the merge call: + +1. Delete the remote branch. +2. `git checkout main && git pull --ff-only`. +3. `git branch -d ` locally. +4. Close the tracking issue manually via the API (the `Closes #N` + keyword is unreliable on this Forgejo instance). + +## Adding a schema change + +1. Edit `src/quartermaster/models.py`. +2. `uv run alembic revision --autogenerate -m "short description"`. + The backup hook runs before alembic touches the DB. +3. Inspect the generated migration under `alembic/versions/` and edit + if needed (autogenerate misses server defaults, check constraints, + and index renames). +4. `uv run alembic upgrade head`. The backup hook runs again. +5. Write tests, update templates / routes. + +## UI verification + +Type checks and pytest do not verify UI behaviour. For any change that +affects the HTML, also: + +1. Start the server against a throwaway DB. +2. Curl the affected endpoints and check for the expected classes and + text in the response bodies. +3. If practical, open the page in a browser and exercise the feature. + Claude cannot do this step from the CLI; ask the human. diff --git a/Home.md b/Home.md new file mode 100644 index 0000000..f5b727a --- /dev/null +++ b/Home.md @@ -0,0 +1,39 @@ +# Quartermaster + +Household budget tracker. FastAPI + HTMX frontend, SQLite backend, managed +with `uv`. Built to practise zero-based budgeting: every income dollar +should be assigned to a category on the plan side, and reconciled against +real spending on the applied side. + +## Pages + +* [Architecture](Architecture) — data model, routes, snapshot semantics, + zero-amount calculation +* [DevelopmentGuide](DevelopmentGuide) — setup, run, test, project + layout, conventions +* [Operations](Operations) — backup script, restore, DB safety rule, + alembic hook, throwaway-DB pattern +* [Roadmap](Roadmap) — shipped and deferred work + +## At a glance + +* `/` — the budget configuration (the plan). One card per section with + name + amount entries. Primary Debt Target points at a Debt Minimums + row. +* `/month/YYYY-MM` — a snapshot of the budget for a specific month with + an `applied` column per entry. Deviations from the snapshot are + visibly tagged. +* Top of every page: a Zero Amount header. Green when every dollar is + assigned, amber when there is unassigned income, red when over-budget. + +## Status + +Shipped: initial scaffold, monthly snapshot view with deviation tags, +database backup script with alembic auto-hook, zero-amount header. See +the [Roadmap](Roadmap) for what is next. + +## Code + +Repo: `archeious/quartermaster` on the self-hosted Forgejo. All work is +issue-driven; branches follow `feat/-`; PRs merge to main +via the API; issues close manually on merge. diff --git a/Operations.md b/Operations.md new file mode 100644 index 0000000..53a3ac1 --- /dev/null +++ b/Operations.md @@ -0,0 +1,117 @@ +# Operations + +## Database safety rule + +The SQLite database holds real user data. It is gitignored, so losing +it means the data is gone. + +**Before any operation that interacts with the database at the schema +or destructive level**, run the backup script: + +```sh +./scripts/backup-db.sh +``` + +That includes: + +* `rm`, `mv`, truncate, or otherwise replacing `quartermaster.db` +* Ad-hoc `sqlite3` sessions that might `DROP`, `DELETE`, or `UPDATE` +* Running any script that mutates the DB outside the running app +* Running the app in "smoke test" / "exercise the flow" mode (the app + writes to the DB; use a throwaway DB for this) + +Routine writes by the running web application on your normal data file +are NOT covered by this rule. Those are expected behaviour. + +Alembic runs `scripts/backup-db.sh alembic` automatically before every +migration, downgrade, `alembic current`, or `alembic revision +--autogenerate`. You do not need to call it manually for ordinary +schema work. + +## Backup script + +```sh +scripts/backup-db.sh [reason] +``` + +* Uses `sqlite3.Connection.backup` (Python's online-backup API) so the + copy is consistent even if the app is writing. No `sqlite3` binary + is required. +* Resolves the DB path from `QUARTERMASTER_DB_URL` (sqlite:/// only) or + defaults to `./quartermaster.db`. +* Writes to `/backups/` by default. Override with + `QUARTERMASTER_BACKUP_DIR=/any/path`. +* Exits 0 when the DB file is missing (safe to call from hooks on a + fresh checkout). +* Exits 1 on a non-file sqlite URL (e.g. `postgres://...`) or an + unsupported URL. + +Filename format: + +``` +quartermaster-YYYYMMDD-HHMMSS-.db +``` + +The slug is derived from the optional reason argument, sanitised to +`[A-Za-z0-9-]`. Default reason is `manual`. + +## Retention + +Forever. The dataset is small (kilobytes per month). Prune manually +from `backups/` if the directory ever grows uncomfortable. + +## Restore + +A backup file is a complete SQLite database. To restore: + +1. Stop the app (`pkill -f uvicorn.*quartermaster` or Ctrl-C). +2. Back up the current state first in case you want to undo the restore: + `./scripts/backup-db.sh pre-restore`. +3. Copy the chosen backup over the live file: + `cp backups/quartermaster-20260417-103000-alembic.db quartermaster.db`. +4. Restart the app. Refresh the browser. + +Alembic version tracking travels with the data, so if the backup was +made on an earlier schema you may need to `uv run alembic upgrade head` +after the restore (which will itself create a backup first). + +## Throwaway-DB pattern for development + +Never smoke-test or experiment against the live data file. Use a tmp +path: + +```sh +export QUARTERMASTER_DB_URL=sqlite:////tmp/qm-dev.db +export QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups +uv run alembic upgrade head +uv run uvicorn quartermaster.main:app --reload +# when done +unset QUARTERMASTER_DB_URL QUARTERMASTER_BACKUP_DIR +rm -rf /tmp/qm-dev.db /tmp/qm-dev-backups +``` + +## Running in "production" + +There is no prod. This is a local single-user app. Run the uvicorn +dev server and reach it at http://127.0.0.1:8000. For automatic +restart on crash, wrap the command in a `systemd --user` unit or a +supervisor of your choice; the app itself does nothing special. + +## Troubleshooting + +### "no such table" on first request + +You forgot `uv run alembic upgrade head` after cloning. The backup +hook will print "no database at ..., nothing to back up" the first +time, which is expected. + +### Alembic reports a revision it cannot locate + +The DB is on a schema version whose migration file is not in the +current checkout. Check out the branch that introduced that revision +or downgrade the DB to a known-good revision before continuing. + +### Backup script exits 1 on a postgres-style URL + +Intentional. The script is sqlite-specific. Switch the URL back to +sqlite:///... or do the backup manually via your Postgres tooling. diff --git a/Roadmap.md b/Roadmap.md new file mode 100644 index 0000000..0cdd877 --- /dev/null +++ b/Roadmap.md @@ -0,0 +1,65 @@ +# Roadmap + +## Shipped + +| # | Title | Merged | +|---|---|---| +| 1 | Initial scaffolding and single-month budget MVP | 2026-04-17 | +| 3 | Monthly budget view with snapshot-from-config and applied tracking | 2026-04-17 | +| 5 | DB backup script invoked before every schema or destructive DB operation | 2026-04-17 | +| 7 | Zero Amount header at the top of budget and month pages | 2026-04-17 | + +## Deferred / on the horizon + +Things we have explicitly called out as future work. Not yet issues; +file one when the time comes. + +### Transaction log behind applied + +Replace the hand-edited `applied` value with a log of dated +transactions per entry per month. `applied` becomes a computed sum. +Implies a new `month_transaction` table, a UI for entering +transactions, and a migration path that preserves existing applied +values as an opening balance. + +### Month close-out / carryover + +When a month ends, allow rolling remaining amounts into the next +month's planned values. Useful for categories where underspend rolls +forward (e.g. Food) and debatable for others (e.g. Rent). Likely a +per-section policy. + +### Copy-forward month + +A "new month" that snapshots a previous month rather than the budget +config. Useful when reality has drifted far enough from the plan that +last month is a better starting point. + +### Cross-month summary / charts + +A view that shows how zero, applied, and category totals have moved +over time. Would use the existing `month_entry` data, no new schema. + +### CSV import / export + +Bulk in for seeding a fresh install, bulk out for taxes or external +analysis. Month-scoped and budget-scoped variants. + +### Auth + +Single-user, local-only today. If the app ever runs on a LAN or is +exposed externally, add a simple password gate or run behind an +SSO-aware reverse proxy. + +### Styling pass + +Current CSS is functional but minimal. A typography and spacing pass +would not change semantics but would improve the "looks like a +spreadsheet" feel. + +## Out of scope (probably forever) + +* Multi-currency +* Multi-user / shared budgets +* Mobile app +* Cloud sync