From aa7ebaa234ea857d488306742825129a6a83b87e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:37:10 -0600 Subject: [PATCH] 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. --- .gitignore | 3 + ...-17-edit-budget-template-entries-design.md | 290 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md diff --git a/.gitignore b/.gitignore index 0e14640..72eebfa 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ + diff --git a/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md b/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md new file mode 100644 index 0000000..b38d6c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md @@ -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.