# 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.