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