docs: refresh wiki for groups, sinking funds, notes, lifecycle, UI redesign

archeious 2026-04-17 17:03:12 -06:00
parent 390b9ffe56
commit fe7edfe973
5 changed files with 307 additions and 124 deletions

@ -2,7 +2,7 @@
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.
manages schema. `uv` manages Python deps.
## Modules
@ -11,33 +11,46 @@ 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
service.py Budget queries, totals, target, zero-amount, group views
month_service.py Month snapshot, deviation, per-month CRUD, lifecycle, group views
groups.py Group enum, labels, section->group mapping, default open
models.py SQLAlchemy models, Section enum, MonthState 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
static/
app.css Full stylesheet (Barlow Condensed + ledger layout)
brand/ Logo assets (full, mark-wide, shield-wide)
```
## Sections
## Sections and groups
```
Section enum (budget + month):
income, fixed_bill, debt_minimum, food, subscription, other
Section enum (budget + month)
income, fixed_bill, debt_minimum, food, subscription, other, sinking_fund
Group enum (display-layer)
income -> [income]
committed -> [fixed_bill, debt_minimum] + Primary Debt Target card
savings -> [sinking_fund]
flexible -> [food, subscription, other]
Default open: income=yes, committed=no, savings=no, flexible=yes
```
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.
The Primary Debt Target is NOT a section. It is a singleton pointer that
selects one of the Debt Minimums rows as the current focus. It is excluded
from zero-amount math since it has no total of its own (its amount is
already counted inside Debt Minimums).
## Budget data model
```
entry
id, section, name, amount, created_at, updated_at
id, section, name, amount, notes (nullable, up to 1024 chars),
created_at, updated_at
debt_target (singleton, id = 1)
debt_target (singleton, id = 1)
id, debt_minimum_id FK entry(id) ON DELETE SET NULL, updated_at
```
@ -48,24 +61,27 @@ the entry itself.
```
month
id, year_month UNIQUE, created_at
id, year_month UNIQUE ("YYYY-MM"),
state ('planning' | 'active' | 'closed', default 'planning'),
activated_at NULL, closed_at NULL,
created_at
month_entry
id, month_id FK month(id) ON DELETE CASCADE,
section, name, planned, applied,
section, name, planned, applied, notes NULL,
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_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
preserves `origin_name`, `origin_planned`, and `notes`. `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.
@ -73,69 +89,139 @@ budget row is deleted.
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.
* `unchanged` — both match. No tag, progress bar renders in the section
baseline colour.
* `edited` — name or planned differs. "Modified" tag in ochre; progress
bar recoloured to indicate drift.
* `new_in_month` — no origin (row added after snapshot). "New this month"
tag in indigo; progress bar tinted indigo.
Notes changes do NOT flip the deviation state. Notes are annotation, not
plan drift.
### Per-month debt target
Copied from the budget's current debt target at creation time, resolved
through `source_entry_id`. Fully editable per month.
## Month lifecycle
Three explicit states. Transitions happen via dedicated routes and are
validated on the server.
```
Planning -- POST /month/{ym}/activate --> Active
Active -- POST /month/{ym}/close --> Closed (applied zero must be $0.00)
Closed -- POST /month/{ym}/reopen --> Active
```
Each transition stamps or clears the relevant timestamp. The close gate
is authoritative on the server; the UI disables the Close button with a
tooltip when the balance is nonzero but would not be trusted alone.
`month_service.ensure_editable(month)` is the guard that every mutation
route calls. When the month is closed it raises `MonthLifecycleError`,
which the route layer translates to an HTTP 400. Inputs on a closed
month carry `disabled`; delete buttons, add forms, and the target form
are omitted entirely from rendered HTML.
Reopen does NOT reverse any sweep — nothing is ever swept automatically.
Reopening just sets the state back to Active and lets you edit again.
## 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.
On both pages:
```
zero = sum(income.amount | .applied | .planned)
- sum(non_income.amount | .applied | .planned)
```
Debt Target is excluded from the calculation because it is a pointer,
not a totalable section.
On the budget page there is one value. On the month page there are two:
Planned (over the planned column) and Applied (over the applied column).
Colour tone:
* zero → green (`tone-zero`)
* positive → amber (`tone-positive`): unassigned income
* negative → red (`tone-negative`): over-budget
* `tone-zero` → sage (balanced)
* `tone-positive` → ochre (unassigned, under-budget)
* `tone-negative` → burgundy (over-budget)
## Routes
### Budget (`src/quartermaster/routes.py`)
```
GET / index page
POST /sections/{section}/entries add entry
GET / index page
POST /sections/{section}/entries add entry
DELETE /entries/{entry_id} remove entry
POST /debt-target set / clear debt target
POST /entries/{entry_id}/notes update entry notes
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
GET /month/{year_month} view page or create-flow
POST /month/{year_month}/create snapshot the budget (lands in Planning)
POST /month/{year_month}/activate Planning -> Active
POST /month/{year_month}/close Active -> Closed (requires applied zero = 0)
POST /month/{year_month}/reopen Closed -> Active
POST /month/{year_month}/sections/{section}/entries add month entry
POST /month/{year_month}/entries/{entry_id} update (name / planned / applied / notes)
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
* The debt target card when a Debt Minimums row was added / edited / deleted
* The Zero Amount widget
* All four group totals (`#group-total-{group}`)
OOB swap is cheaper than re-rendering the full page and keeps totals
in sync without a reload.
OOB is used so section-level changes keep the page-level summary widgets
accurate 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.
* Month entry rows carry three inline inputs (`name`, `planned`,
`applied`) plus a notes input, each 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.
* Transition buttons (activate, close, reopen) POST with `hx-swap="none"`
and the server returns 204 + `HX-Redirect: /month/{year_month}` so the
page re-renders cleanly in the new state.
## Visual identity
* **Typography**: Barlow Condensed (300-800 + italic) imported from
Google Fonts. Barlow proportional as a secondary pair. All numeric
columns carry `font-variant-numeric: tabular-nums` plus
`font-feature-settings: "lnum" 1, "tnum" 1`.
* **Palette** (CSS variables in `app.css`):
* `--paper #f5efe0` warm cream background with layered radial gradients
for depth
* `--ink #1a1814` warm near-black
* `--accent #732629` burgundy, sampled from the logo shield
* `--ochre #9c6b1a` under-budget / deviation
* `--sage #2d4a30` balanced / at-plan
* `--indigo #353a5e` new-in-month
* **Layout primitives**:
* `.zero-widget` grid at the top of each page; logo on the left, zero
in the centre, flanking labels
* `details.group` collapsible groups with a hairline CSS chevron that
rotates on `[open]`
* `.section` with a small-caps header and an inline subtotal
* `table.entries` with a 4-column grid (name / planned / applied /
actions) and a 2px progress bar on each row's bottom border
* `.target-section` with a burgundy left bar and `↳` margin glyph
* `.state-badge` tracked-caps label with bullet separators
## Testing
@ -144,3 +230,4 @@ in sync without a reload.
share the same in-memory engine across threads via `StaticPool`.
* Backup script tests shell out and assert on filenames and sqlite
round-trips.
* Full suite runs in under 4 seconds.

@ -4,8 +4,7 @@
* 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)
* SQLite via Python's `sqlite3` module (no external binary required)
## Setup
@ -35,22 +34,25 @@ uv run pytest
```
Tests use an in-memory SQLite engine; no separate migration step
needed.
needed. Full suite runs in under 4 seconds.
## 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.
**Do not smoke-test or experiment against the live `./quartermaster.db`.**
See the DB safety rule in [Operations](Operations) for the full
reasoning. For any dev churn that mutates data (seeding fake entries,
exercising the HTMX flow, generating new alembic migrations), use a
throwaway path:
```sh
QUARTERMASTER_DB_URL=sqlite:////tmp/qm-dev.db \
QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups \
uv run alembic upgrade head
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
QUARTERMASTER_DB_URL=sqlite:////tmp/qm-dev.db \
QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups \
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
```
## Project layout
@ -59,38 +61,50 @@ QUARTERMASTER_BACKUP_DIR=/tmp/qm-dev-backups \
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
routes_month.py Monthly view handlers + lifecycle transitions
service.py Budget service: entries, target, zero-amount, groups
month_service.py Month service: snapshot, deviation, lifecycle, groups
groups.py Group enum, labels, section->group mapping
models.py SQLAlchemy models, Section enum, MonthState enum
db.py Engine, session, PRAGMA foreign_keys=ON
config.py DB URL resolution
templates/
base.html
base.html Barlow Condensed imports + favicon
index.html Budget config page
month.html Month view page
month_create.html Create-flow for missing month
month_create.html Create-flow for a 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
section.html Budget section card
target_card.html Budget debt target card
budget_zero.html Budget Zero Amount widget (with logo)
budget_group_totals.html OOB swap for all four budget group totals
month_section.html Month section card with inline HTMX edits
month_target.html Month debt target card
month_zero.html Month Zero Amount widget (with logo)
month_group_totals.html OOB swap for all four month group totals
month_nav.html prev / title / next / state / lifecycle button
static/
app.css Full stylesheet (Barlow Condensed + ledger layout)
brand/ Optimised logo assets
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
test_service.py Budget service
test_routes.py Budget routes
test_month_service.py Month snapshot + CRUD
test_month_routes.py Month routes
test_zero_amount.py Zero-amount math + widgets
test_groups.py Group mapping + subtotals + default-open
test_notes.py Notes field + snapshot copy
test_month_lifecycle.py Transitions + close gate + edit locking
test_backup_script.py Backup script shell behaviour
CLAUDE.md DB safety rule, repo-level instructions
docs/
wiki/ Cloned wiki (gitignored)
mockups/ Design history + mockup HTML
```
## Conventions
@ -108,24 +122,23 @@ 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.
Each commit is one logical change. Typical PRs in this repo have 3-6
commits grouped by layer (data model, service, routes, templates,
tests, docs). Commit subjects under 72 chars, body under 80, trailers
include `Refs #<issue>` and the Claude co-author line.
### Merge flow
PRs merge via the Forgejo API (or web UI merge button), NOT `git merge`
locally. After the merge call:
PRs merge via the Forgejo API (or the web UI merge button), NOT
`git merge` locally. After the merge call:
1. Delete the remote branch.
1. Delete the remote branch via the API.
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
### Adding a schema change
1. Edit `src/quartermaster/models.py`.
2. `uv run alembic revision --autogenerate -m "short description"`.
@ -134,9 +147,9 @@ locally. After the merge call:
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.
5. Write tests, update templates and routes.
## UI verification
### UI verification
Type checks and pytest do not verify UI behaviour. For any change that
affects the HTML, also:
@ -144,5 +157,18 @@ 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.
3. Open the page in a browser and exercise the feature.
4. Claude cannot run a real browser from the CLI; the human eyeball is
the final check before merge.
### Design workflow
UI experiments live under `docs/mockups/` as self-contained HTML files
(typography + stylesheet inline, real shared logo assets). Iterate
there first, then port the shipped direction into
`src/quartermaster/static/app.css` and the Jinja templates.
Typography discipline: stick with Barlow Condensed for the family; use
weight, size, letter-spacing, and italic for hierarchy. Reserve
Fraunces / editorial serifs for future documentation or design
explorations; they are not installed for the app.

69
Home.md

@ -7,30 +7,65 @@ 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
* [Architecture](Architecture) — data model, route map, snapshot
semantics, lifecycle states, deviation rules, zero-amount calculation,
visual identity
* [DevelopmentGuide](DevelopmentGuide) — prerequisites, setup, run, test,
project layout, conventions, throwaway-DB pattern
* [Operations](Operations) — backup script, restore, DB safety rule,
alembic hook, throwaway-DB pattern
* [Roadmap](Roadmap) — shipped and deferred work
alembic hook
* [Roadmap](Roadmap) — shipped features and what's deferred
## 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.
* `/` — the budget configuration (the plan). Sections organised into four
collapsible groups: **Income**, **Committed** (fixed bills + debt
minimums + Primary Debt Target), **Savings** (sinking funds), and
**Flexible** (food, subscriptions, other).
* `/month/YYYY-MM` — a snapshot of the budget for a specific calendar
month. Adds an `applied` column per entry to track actuals. Rows carry
deviation tags when edited or added post-snapshot.
* **Zero Amount header** on both pages. Green when every dollar is
assigned, amber when unassigned income remains, red when over-budget.
The logo anchors the left of the hero; Applied / Planned flank the big
number on month pages.
* **Per-entry notes** — free-text annotation on every row. Copied through
at snapshot time; editable inline.
## Month lifecycle
Each month moves through three explicit states:
| State | Editable? | Transition |
|---|---|---|
| Planning | yes | *Activate* → Active |
| Active | yes | *Close* → Closed (requires applied zero = $0.00) |
| Closed | no | *Reopen* → Active |
Nothing sweeps automatically. The Primary Debt Target is a hint about
where leftover applied dollars belong; filling it is the user's job.
## Visual identity
* **Typography**: Barlow Condensed throughout (weights 300-800 + italic),
Barlow proportional as a secondary pair. Tabular lining figures in
every numeric column.
* **Palette**: warm cream paper, warm near-black ink, burgundy accent
(`#732629`, sampled from the logo shield). Sage / ochre / indigo for
balance / under-spend / new-this-month states.
* **Layout**: ledger-style density. Entry rows carry a 2-pixel progress
bar along the bottom border that fills to the applied/planned ratio
and overshoots in burgundy when over-budget.
* **Brand**: the logo's shield encloses a Q, a $, and a `$0` — literally
the zero-based thesis. It sits in the left column of every zero hero.
## 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.
Shipped: initial scaffold, monthly snapshot with deviation tags, database
backup script with alembic auto-hook, zero-amount header, section groups
with collapsible headers, sinking funds section, per-entry notes, month
lifecycle with the balance gate, UI redesign in Barlow Condensed + logo.
See the [Roadmap](Roadmap) for what is next.
## Code

@ -105,6 +105,13 @@ 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.
### "no such column: month_entry.notes" (or similar)
You pulled code with a new column but did not apply the migration.
Run `uv run alembic upgrade head`. The backup hook backs up the live
DB first, then the migration adds the missing column. Common after
a pull that includes schema work (notes field, month lifecycle).
### Alembic reports a revision it cannot locate
The DB is on a schema version whose migration file is not in the
@ -115,3 +122,17 @@ or downgrade the DB to a known-good revision before continuing.
Intentional. The script is sqlite-specific. Switch the URL back to
sqlite:///... or do the backup manually via your Postgres tooling.
## Current schema
Applied migrations at time of writing:
| Revision | Description |
|---|---|
| `f1ccdc4bc1bf` | initial schema (`entry`, `debt_target`) |
| `03ebe3c07262` | add month snapshot tables (`month`, `month_entry`, `month_debt_target`) |
| `ec804bdf366d` | add `notes` column to `entry` and `month_entry` |
| `a4ec4f8f6e9f` | add month lifecycle columns (`state`, `activated_at`, `closed_at`) |
After pulling new code, `uv run alembic upgrade head` walks the chain
and the backup hook fires between each hop.

@ -8,11 +8,16 @@
| 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 |
| 9 | Gitignore the local wiki checkout at docs/wiki/ | 2026-04-17 |
| 11 | Section groups with collapsible headers + Sinking Funds section | 2026-04-17 |
| 13 | Notes field per entry | 2026-04-17 |
| 15 | Month lifecycle: Planning, Active, Closed with reconciliation gate | 2026-04-17 |
| 17 | UI redesign: condensed-sans ledger style with logo in the zero hero | 2026-04-17 |
## Deferred / on the horizon
## Deferred
Things we have explicitly called out as future work. Not yet issues;
file one when the time comes.
Things we have explicitly called out as future work. File an issue when
the time comes.
### Transaction log behind applied
@ -22,23 +27,25 @@ 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
### Closed-month "archived" visual treatment
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.
Closed months currently carry a muted state badge and disabled inputs.
A deeper treatment would desaturate the page palette, add a subtle
"Closed" watermark, and visually retire the closed month from the
editing canvas.
### 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.
last month is a better starting point. Would augment `create_month` to
accept a `from_month_id` parameter.
### Cross-month summary / charts
### Cross-month summary and 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.
A view that shows how zero-amount, applied, and category totals have
moved over time. Would use the existing `month_entry` data, no new
schema. First pass: a sparkline per category across the last N months.
### CSV import / export
@ -51,11 +58,18 @@ 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
### localStorage persistence of collapsed groups
Current CSS is functional but minimal. A typography and spacing pass
would not change semantics but would improve the "looks like a
spreadsheet" feel.
Today the open/closed state of section groups resets on every page
load. A tiny `localStorage` hook would remember which groups a given
browser last had open.
### Budget entry name / amount edit
Budget entries currently require delete-and-recreate to change the
name or amount. Notes are editable inline. Extending the inline edit
pattern to name and amount on the budget page would mirror what the
month page already allows.
## Out of scope (probably forever)