docs: initial wiki standard set (Home, Architecture, DevelopmentGuide, Operations, Roadmap)

archeious 2026-04-17 12:13:33 -06:00
commit 390b9ffe56
5 changed files with 515 additions and 0 deletions

146
Architecture.md Normal file

@ -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.

148
DevelopmentGuide.md Normal file

@ -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/<issue>-<slug> new feature
fix/<issue>-<slug> bug fix
refactor/<issue>-<slug> internal change
chore/<issue>-<slug> 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 #<issue>` 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 <branch>` 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.

39
Home.md Normal file

@ -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/<issue>-<slug>`; PRs merge to main
via the API; issues close manually on merge.

117
Operations.md Normal file

@ -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 <reason>
```
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 `<dir-of-db>/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-<slug>.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.

65
Roadmap.md Normal file

@ -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