docs: initial wiki standard set (Home, Architecture, DevelopmentGuide, Operations, Roadmap)
commit
390b9ffe56
5 changed files with 515 additions and 0 deletions
146
Architecture.md
Normal file
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
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
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
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
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
|
||||
Loading…
Reference in a new issue