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.
290 lines
12 KiB
Markdown
290 lines
12 KiB
Markdown
# 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.
|