quartermaster/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md
archeious aa7ebaa234 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.
2026-04-17 18:37:10 -06:00

12 KiB

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:

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