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:
parent
c6126852a2
commit
aa7ebaa234
2 changed files with 293 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in a new issue