docs: spec for editing budget template entries (#21)

Adds the brainstorm-phase design for inline name/amount/notes edit on
the budget template page. Layout variant A (swap in place), notes folded
into edit mode, no schema change, forward-facing only.

Also adds .superpowers/ to .gitignore so the brainstorm companion's
working dir does not end up tracked.
This commit is contained in:
archeious 2026-04-17 18:37:10 -06:00
parent c6126852a2
commit aa7ebaa234
2 changed files with 293 additions and 0 deletions

3
.gitignore vendored
View file

@ -172,3 +172,6 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Superpowers brainstorm companion working dir
.superpowers/

View file

@ -0,0 +1,290 @@
# Edit budget template entries (name and amount)
## Problem
The budget template page (index, `partials/section.html`) lets a user add
an entry, delete an entry, and edit the notes on an entry. It does not
let a user edit the **name** or **amount** of an existing entry. If a
subscription price changes (e.g. Twitch goes from $10.99 to $11.99), the
only workaround is delete + re-add, which also orphans its `Entry.id`
and breaks the historical link from existing `MonthEntry.source_entry_id`
rows.
## Goal
Add inline edit for name, amount, and notes on a budget template entry.
The change must be **forward-facing only**: existing `Month` rows
(planning, active, closed) keep their snapshotted `MonthEntry.planned`,
`MonthEntry.name`, and deviation baseline (`origin_name`,
`origin_planned`) untouched. Only months created **after** the edit see
the new values, via the existing `create_month` snapshot flow.
## Non-goals
- No schema change, no migration, no backfill.
- No bulk-apply-to-existing-months affordance. The user explicitly does
not want retroactive edits.
- No change to the month-view inline-edit UX.
- No change to the add-entry flow.
## Architecture
The feature is essentially already implemented at the data layer. The
snapshot-over-mirror model guarantees that `Entry` is read **only** by
`create_month` (verified in `month_service.create_month`, where
`budget_entries` is the single read site for the purpose of building
`MonthEntry` rows). All other reads of planned amounts and names go
through `MonthEntry` directly.
Consequently:
- Updating `Entry.name` or `Entry.amount` has no effect on any existing
`Month` or `MonthEntry`.
- `MonthEntry.origin_name` and `MonthEntry.origin_planned` remain at
their original snapshot values, so deviation tagging (`modified` /
`new this month`) continues to compare each month's rows against that
month's baseline, not against the live template. This is the correct
behavior and the design intentionally preserves it.
The work is therefore purely a UI + service + route addition with no
data-model changes.
## UI behavior
Layout **variant A, "swap in place"**, chosen over alternatives B
(expanding drawer) and C (always-editable) for its horizontal compactness
and the clearer read/edit mode separation. Full mockups in
`.superpowers/brainstorm/.../layout-options.html`.
### Read row (default)
Single line:
```
[name] [$amount] ✎ ✕
```
- If `entry.notes` is set, render a small italic badge after the name
(e.g. `Spotify · family plan`). If notes is empty or null, render
nothing for notes (no placeholder row, no empty affordance).
- The edit (✎) and delete (✕) icons use the existing
`.entry-actions button.delete` hover-fade pattern, extended to the new
edit button.
- The existing separate notes row (`tr.entry-notes-row`) is removed.
### Edit row (swaps into the same row slot)
Single line:
```
[name input] [amount input] [notes input] Save Cancel
```
- Name, amount, and notes inputs use the **bordered-padded** form style
already defined for `.add-form input` / `.month-add-form input`
(bordered, padded, `--rule` border, `--paper-soft` background,
border darkens to `--ink` on focus). This matches the mockup the user
approved (variant A) and makes edit mode visually distinct from the
underline-style always-editable inputs on the month view. The month
view's underline style is appropriate for fields that are always
editable; this feature uses a discrete edit mode, so bordered inputs
are the correct visual language.
- Notes input is always present while editing, styled italic muted and
using the same bordered style. No separate "+note" affordance.
- Save commits the edit atomically (all three fields in one request);
Cancel discards and returns to read mode.
- Grid columns shift when in edit mode: name narrower, amount unchanged
size, notes absorbs the rest of the row, followed by the two buttons.
### Toggle mechanism
Server-rendered, HTMX-driven. No client-side state; matches every other
interaction on the index page.
- Click ✎: `GET /entries/{id}/edit` returns the `#section-{section}`
partial with `editing_id` set to `{id}`. Swap target:
`#section-{section}`, `outerHTML`.
- Click Save: `POST /entries/{id}` with form fields `name`, `amount`,
`notes`. Updates the `Entry` row, then returns the section partial in
read mode. Plus OOB partials for `#zero-widget` (via
`partials/budget_zero.html`) and each `#group-total-{group}` span
(via `partials/budget_group_totals.html`) because the amount change
affects both; plus the target card (OOB `#section-debt_target`) if
the entry's section is `debt_minimum`. These are the exact
OOB-swap targets already used by `create_entry` and `remove_entry`
in `routes.py`; no new OOB wiring is needed.
- Click Cancel: `GET /sections/{section}` returns the section partial
in plain read mode (no `editing_id`). Same swap target. No server
state to unwind.
### Concurrent edits within a section
Only one row can be in edit mode at a time, because the section is
re-rendered from scratch on each edit-mode transition. If the user
clicks ✎ on row A, then clicks ✎ on row B, the server renders the
section with row B editing and row A back in read mode. No extra logic
required; this falls out of the stateless swap pattern.
## Backend API
### `service.update_entry`
New function in `src/quartermaster/service.py`:
```python
_NOTES_SENTINEL = object()
def update_entry(
db: Session,
entry_id: int,
*,
name: str | None = None,
amount: Decimal | None = None,
notes: str | None | object = _NOTES_SENTINEL,
) -> Entry | None:
"""Update an Entry's name, amount, or notes.
Mirrors month_service.update_month_entry in shape.
Returns the updated Entry, or None if not found.
"""
```
Semantics:
- `name=None` and `amount=None` mean "do not change this field".
- `notes=_NOTES_SENTINEL` means "do not change"; `notes=None` or
`notes=""` clear the notes to null (via the existing `_clean_notes`).
- Name, when provided, must be non-empty after strip; caller validates.
- Amount validation (non-negative, decimal) is the caller's
responsibility; the existing `_parse_amount` helper handles this at
the route layer.
The existing `set_entry_notes` becomes a thin wrapper around
`update_entry(db, entry_id, notes=notes)`, or is removed and its one
route caller migrated directly. Preference: remove, migrate the caller.
The template's notes-only row is being removed anyway, so the
dedicated route is no longer needed.
### Routes
Three new / changed routes in `src/quartermaster/routes.py`:
| Method | Path | Purpose | Response |
|--------|--------------------------|-------------------------|---------------------------------------------------------------------|
| `GET` | `/entries/{id}/edit` | Enter edit mode | `partials/section.html` with `editing_id={id}` |
| `POST` | `/entries/{id}` | Save edits | Section read-mode + OOB zero + OOB group totals (+ OOB target card if section is `debt_minimum`) |
| `GET` | `/sections/{section}` | Cancel / plain re-render| `partials/section.html` read mode |
Form fields on `POST /entries/{id}`:
- `name`: required, non-empty after strip. 400 if empty.
- `amount`: required, parseable Decimal, non-negative. 400 on parse
failure or negative.
- `notes`: optional, passed through `_clean_notes`.
404 returned from any of the three if `entry_id` does not resolve to an
`Entry`.
The existing `POST /entries/{entry_id}/notes` route and its UI row are
removed. Notes are now edited only through the edit-mode flow.
### Template change
`src/quartermaster/templates/partials/section.html` gains an
`editing_id` context variable (default `None`). For each entry:
- If `entry.id == editing_id`: render the edit row (name input, amount
input, notes input, Save, Cancel).
- Else: render the read row (name with optional notes badge, amount,
edit button, delete button).
The separate `tr.entry-notes-row` block is deleted.
The existing `_render_section` helper in `routes.py` gains an optional
`editing_id: int | None = None` argument and threads it into the
template context. All existing callers (`create_entry`, `remove_entry`,
the new `POST /entries/{id}` save, the new `GET /sections/{section}`
cancel) pass the default `None`. Only the new `GET /entries/{id}/edit`
handler passes a concrete id. In the template, the variable is
referenced via `{% if editing_id is not none and entry.id == editing_id %}`
so rendering is defensive if the variable is ever missing.
## CSS changes
In `src/quartermaster/static/app.css`:
- New grid template for `tr.entry` (read mode): drop the current
third "empty" column, collapse to `minmax(0, 1fr) 5.5rem auto`
(name / amount / actions). The actions cell holds both ✎ and ✕.
- New grid template for `tr.entry.editing` (edit mode):
`minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto` (name input / amount
input / notes input / buttons).
- Styles for the edit icon mirroring the existing delete icon's
hover-fade behavior.
- Styles for the Save / Cancel buttons matching the existing
`.lifecycle-form button` treatment.
- `.note-badge` for the inline notes badge on read-mode rows.
- Delete the `tr.entry-notes-row` rules (no longer used).
## Testing
### `tests/test_service.py`, new tests for `update_entry`
- Updates name only; amount and notes unchanged.
- Updates amount only; name and notes unchanged.
- Updates notes: set from null to text, set from text to different
text, clear with empty string, clear with `None`.
- Updates all three atomically.
- Returns `None` when `entry_id` does not exist.
- **Forward-facing invariant, direct DB assertion:** create a month
(which snapshots the entry), call `update_entry` with new name and
amount, assert the month's `MonthEntry.name`, `.planned`,
`.origin_name`, and `.origin_planned` are all unchanged.
### `tests/test_routes.py`, new tests for the edit flow
- `GET /entries/{id}/edit` returns 200; response body contains inputs
with `name="name"` and `name="amount"`; does not contain the entry's
current name rendered as read-mode text.
- `POST /entries/{id}` with valid name + amount + notes returns 200;
response contains the new values rendered in read mode; response
contains OOB swaps targeting `#budget-zero` and
`#budget-group-totals`.
- `POST /entries/{id}` for a `debt_minimum`-section entry additionally
includes an OOB target card.
- `POST /entries/{id}` with blank name returns 400.
- `POST /entries/{id}` with negative amount returns 400.
- `POST /entries/{id}` with non-numeric amount returns 400.
- `POST /entries/{id}` for a missing id returns 404.
- `GET /entries/{id}/edit` for a missing id returns 404.
- `GET /sections/{section}` returns 200 in plain read mode.
- The legacy `POST /entries/{id}/notes` route returns 404 (removed).
### `tests/test_month_lifecycle.py` (or a new `test_template_edit_isolation.py`)
Headline end-to-end test:
1. Seed an `Entry` (e.g. Twitch, $10.99).
2. Create Month `2026-04`.
3. Assert the month's `MonthEntry` for Twitch has `planned = 10.99`.
4. Call `service.update_entry` to set Twitch to `11.99`.
5. Assert the month's `MonthEntry` is still `10.99` and its `origin_planned` is still `10.99`.
6. Create Month `2026-05`.
7. Assert the new month's `MonthEntry` for Twitch has `planned = 11.99`.
This test proves the user-facing guarantee end-to-end with no
mocking.
## Out of scope / future work
- "Apply this change to the current active month too" is an explicit
non-goal, and we believe the right workflow for that case is to edit
the month-entry directly on the month view (which already works).
- Bulk edit, CSV import, undo, or edit history are all out of scope.
## Issue
Issue-driven work: an open Forgejo issue for this feature needs to be
created before implementation begins. Suggested title: "Edit
name/amount on budget template entries (forward-only)". Issue body
should link to this spec.