From ab5b88a52bb72872a44b92d471eb6cd6d7b6f51c Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:42:09 -0600 Subject: [PATCH] docs: implementation plan for editing template entries (#21) Ten-task TDD plan: service.update_entry, three new routes, template rewrite, CSS, test migration for the removed notes route, end-to-end isolation test, manual UI verification, and PR opening. --- ...2026-04-17-edit-budget-template-entries.md | 1256 +++++++++++++++++ 1 file changed, 1256 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md diff --git a/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md b/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md new file mode 100644 index 0000000..1d5c091 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md @@ -0,0 +1,1256 @@ +# Edit budget template entries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add inline edit for name, amount, and notes on budget template entries. Changes are forward-facing only: existing `Month` snapshots are untouched, only newly created months pick up the edited values. + +**Architecture:** Snapshot-over-mirror already guarantees isolation at the data layer. This plan adds a `service.update_entry` function, three new HTMX routes (`GET /entries/{id}/edit`, `POST /entries/{id}`, `GET /sections/{section}`), a rewritten `partials/section.html` with a read-row / edit-row toggle driven by an `editing_id` template variable, and matching CSS. The old notes-only route and its separate notes row are removed. + +**Tech Stack:** FastAPI, SQLAlchemy, Jinja2 templates, HTMX, pytest. + +**Spec:** `docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md` + +**Issue:** #21 ("Edit name/amount on budget template entries (forward-only)") + +**Branch:** `feat/21-edit-template-entries` (already created, spec already committed) + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/quartermaster/service.py` | Modify | Add `update_entry`; remove `set_entry_notes` (migrated) | +| `src/quartermaster/routes.py` | Modify | Add `GET /entries/{id}/edit`, `POST /entries/{id}`, `GET /sections/{section}`; remove `POST /entries/{id}/notes`; thread `editing_id` through `_render_section` | +| `src/quartermaster/templates/partials/section.html` | Modify | Rewrite: read row with optional note badge, edit row with inputs + Save/Cancel, driven by `editing_id`; remove separate notes row | +| `src/quartermaster/static/app.css` | Modify | New grid for read row (no empty column); new edit-row layout and styling; note-badge style; remove `.entry-notes-row` rules | +| `tests/test_service.py` | Modify | Unit tests for `update_entry` | +| `tests/test_routes.py` | Modify | Route tests for the three new endpoints | +| `tests/test_notes.py` | Modify | Migrate tests that reference removed `set_entry_notes`, removed `POST /entries/{id}/notes` route, removed `entry-notes-row` class | +| `tests/test_template_edit_isolation.py` | Create | End-to-end test: edit template, assert existing month unchanged, assert new month sees edit | + +## Test commands reference + +Run a single test: +``` +uv run pytest tests/test_service.py::test_name -v +``` + +Run the full suite: +``` +uv run pytest -q +``` + +--- + +## Task 1: Service `update_entry` function + +**Files:** +- Modify: `src/quartermaster/service.py` +- Modify: `tests/test_service.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_service.py`: + +```python +def test_update_entry_name_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, name="Twitch Prime") + assert updated is not None + assert updated.name == "Twitch Prime" + assert updated.amount == Decimal("10.99") + + +def test_update_entry_amount_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, amount=Decimal("11.99")) + assert updated is not None + assert updated.name == "Twitch" + assert updated.amount == Decimal("11.99") + + +def test_update_entry_notes_set_and_clear(db): + entry = service.add_entry(db, Section.other, "Parking", Decimal("25.00")) + updated = service.update_entry(db, entry.id, notes="work") + assert updated is not None + assert updated.notes == "work" + updated = service.update_entry(db, entry.id, notes="") + assert updated is not None + assert updated.notes is None + service.update_entry(db, entry.id, notes="work again") + updated = service.update_entry(db, entry.id, notes=None) + assert updated is not None + assert updated.notes is None + + +def test_update_entry_all_three_atomic(db): + entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) + updated = service.update_entry( + db, + entry.id, + name="Groceries (Costco)", + amount=Decimal("450.00"), + notes="weekly run", + ) + assert updated is not None + assert updated.name == "Groceries (Costco)" + assert updated.amount == Decimal("450.00") + assert updated.notes == "weekly run" + + +def test_update_entry_notes_untouched_when_sentinel(db): + entry = service.add_entry( + db, Section.other, "Gift", Decimal("25.00"), notes="birthday" + ) + updated = service.update_entry(db, entry.id, amount=Decimal("30.00")) + assert updated is not None + assert updated.notes == "birthday" + + +def test_update_entry_missing_returns_none(db): + assert service.update_entry(db, 9999, name="Whatever") is None + + +def test_update_entry_does_not_mutate_existing_month_snapshot(db): + from quartermaster import month_service + entry = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + month = month_service.create_month(db, "2026-04") + me = next(e for e in month.entries if e.source_entry_id == entry.id) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" + + service.update_entry( + db, entry.id, name="Twitch Prime", amount=Decimal("11.99") + ) + db.refresh(me) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_service.py::test_update_entry_name_only tests/test_service.py::test_update_entry_amount_only tests/test_service.py::test_update_entry_notes_set_and_clear tests/test_service.py::test_update_entry_all_three_atomic tests/test_service.py::test_update_entry_notes_untouched_when_sentinel tests/test_service.py::test_update_entry_missing_returns_none tests/test_service.py::test_update_entry_does_not_mutate_existing_month_snapshot -v +``` + +Expected: all fail with `AttributeError: module 'quartermaster.service' has no attribute 'update_entry'`. + +- [ ] **Step 3: Implement `update_entry`** + +In `src/quartermaster/service.py`, add a module-level sentinel (below the imports) and the function (after `set_entry_notes`, before `delete_entry`): + +```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: + entry = db.get(Entry, entry_id) + if entry is None: + return None + if name is not None: + entry.name = name.strip() + if amount is not None: + entry.amount = amount + if notes is not _NOTES_SENTINEL: + entry.notes = _clean_notes(notes) # type: ignore[arg-type] + db.commit() + db.refresh(entry) + return entry +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_service.py -v -k update_entry +``` + +Expected: all 7 `update_entry` tests pass. + +- [ ] **Step 5: Run the full suite to confirm no regression** + +``` +uv run pytest -q +``` + +Expected: 117 + 7 = 124 passing. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/service.py tests/test_service.py +git commit -m "feat(service): add update_entry for template rows (#21)" +``` + +--- + +## Task 2: Rewrite `partials/section.html` and CSS + +This task changes the template structure and styling. Some existing tests in `test_notes.py` assert the old markup (`entry-notes-row` class, `value="weekly"` inside a notes input on the read-mode page). Those tests get migrated in this same task so the suite stays green. + +**Files:** +- Modify: `src/quartermaster/templates/partials/section.html` +- Modify: `src/quartermaster/static/app.css` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Migrate `test_notes.py` assertions to new markup (tests first)** + +Replace the following tests in `tests/test_notes.py`: + +```python +def test_budget_page_renders_notes_inputs(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "entry-notes-row" in response.text + assert "due 1st" in response.text +``` + +with: + +```python +def test_budget_page_renders_note_badge_when_notes_set(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "note-badge" in response.text + assert "due 1st" in response.text + + +def test_budget_page_omits_note_badge_when_notes_empty(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "note-badge" not in response.text +``` + +Leave the rest of `test_notes.py` alone for now (the `POST /entries/{id}/notes` route tests will be migrated in Task 6 when the route is removed). + +- [ ] **Step 2: Rewrite `partials/section.html`** + +Replace the entire file with: + +```html +
+
+

{{ section.label }}

+ + ${{ '{:,.2f}'.format(section.total) }} + +
+
+ {% for entry in section.entries %} + {% if editing_id is not none and entry.id == editing_id %} +
+ + + +
+ + +
+
+ {% else %} +
+ + {{ entry.name }}{% if entry.notes %}{{ entry.notes }}{% endif %} + + ${{ '{:,.2f}'.format(entry.amount) }} +
+ + +
+
+ {% endif %} + {% else %} +
No entries yet.
+ {% endfor %} +
+
+ + add {{ section.label|lower }} +
+ + + + +
+
+
+
+
+``` + +- [ ] **Step 3: Update CSS in `src/quartermaster/static/app.css`** + +Replace the entire `/* =============== ENTRY TABLE =============== */` block and the `/* Notes row */` block (roughly lines 364 through 563) with the new layout. Use this as the replacement block: + +```css +/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */ + +.budget-entries { display: flex; flex-direction: column; } + +.entry-row.reading { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem auto; + gap: 0.6rem; + align-items: baseline; + padding: 0.26rem 0.25rem 0.28rem; + border-bottom: 1px dotted var(--rule); + position: relative; +} +.entry-row.reading:hover { background: var(--paper-stripe); } + +.entry-row.reading .entry-name { + font-family: var(--sans); + font-weight: 500; + font-size: 1.02rem; + color: var(--ink); + letter-spacing: 0.01em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.entry-row.reading .note-badge { + font-family: var(--sans); + font-style: italic; + font-size: 0.82rem; + color: var(--muted); + margin-left: 0.5rem; + letter-spacing: 0.02em; + opacity: 0.85; +} +.entry-row.reading .note-badge::before { + content: "· "; + color: var(--rule); + font-style: normal; +} +.entry-row.reading .entry-amount { + font-family: var(--sans); + font-weight: 500; + font-size: 1rem; + color: var(--ink); + font-feature-settings: "lnum" 1, "tnum" 1; + text-align: right; + min-width: 0; +} + +.entry-row.reading .entry-actions { + display: flex; + gap: 0.3rem; + align-items: center; +} +.entry-row.reading .entry-actions button { + background: none; + border: none; + cursor: pointer; + padding: 0; + line-height: 1; + color: var(--rule); + opacity: 0; + transition: color 0.12s ease, opacity 0.12s ease; +} +.entry-row.reading:hover .entry-actions button { opacity: 1; } +.entry-row.reading .entry-actions button.edit { + font-size: 0.95rem; + color: var(--rule); +} +.entry-row.reading .entry-actions button.edit:hover { color: var(--ink); } +.entry-row.reading .entry-actions button.delete { + font-size: 1.2rem; + font-weight: 400; +} +.entry-row.reading .entry-actions button.delete:hover { color: var(--accent); } + +/* Edit row: form that replaces the reading row in place */ +.entry-row.editing { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto; + gap: 0.5rem; + align-items: center; + padding: 0.26rem 0.25rem 0.28rem; + border-bottom: 1px dotted var(--rule); + background: var(--paper-soft); + margin: 0; +} +.entry-row.editing input { + font-family: var(--sans); + font-size: 0.95rem; + padding: 0.2rem 0.4rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + outline: none; + transition: border-color 0.12s; + min-width: 0; +} +.entry-row.editing input:focus { border-color: var(--ink); } +.entry-row.editing input[type="number"] { + text-align: right; + font-variant-numeric: tabular-nums; +} +.entry-row.editing .notes-input { + font-style: italic; + font-size: 0.88rem; + color: var(--muted); +} +.entry-row.editing .entry-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} +.entry-row.editing .save-btn, +.entry-row.editing .cancel-btn { + font-family: var(--sans); + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.22rem 0.6rem; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} +.entry-row.editing .save-btn { + border: 1px solid var(--ink); + background: var(--paper-soft); + color: var(--ink); +} +.entry-row.editing .save-btn:hover { + background: var(--sage); + color: var(--paper); + border-color: var(--sage); +} +.entry-row.editing .cancel-btn { + border: 1px solid var(--rule); + background: transparent; + color: var(--muted); +} +.entry-row.editing .cancel-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.empty-row { + padding: 0.4rem 0.5rem; + color: var(--muted); + font-style: italic; + font-size: 0.9rem; +} + +.add-row { + padding: 0.4rem 0.25rem 0.2rem; + grid-column: 1 / -1; +} + +@media (max-width: 520px) { + .entry-row.reading { + grid-template-columns: minmax(0, 1fr) 4.6rem auto; + gap: 0.4rem; + } + .entry-row.editing { + grid-template-columns: minmax(0, 1fr) 4.6rem; + gap: 0.4rem; + } + .entry-row.editing .notes-input, + .entry-row.editing .entry-actions { grid-column: 1 / -1; } +} +``` + +Remove the obsolete `table.entries`, `tr.entry`, `tr.entry-notes-row`, and `tr.empty`, `tr.add-row` rules in the old block. Keep the `details.add-entry` rules (add-entry disclosure still works). Keep the `.tag` rules. Keep the `.section.target-section` rules. Keep the `.add-form` rules. Keep everything below `=============== PRIMARY DEBT TARGET ===============` onward. + +- [ ] **Step 4: Thread `editing_id=None` default into `_render_section`** + +In `src/quartermaster/routes.py`, update `_render_section` (around line 41): + +```python +def _render_section( + request: Request, + db: Session, + section: Section, + editing_id: int | None = None, +) -> HTMLResponse: + view = _section_view(db, section) + return templates.TemplateResponse( + request, + "partials/section.html", + {"section": view, "editing_id": editing_id}, + ) +``` + +Existing callers (`create_entry`, `remove_entry`, `update_entry_notes`) keep using the positional form; they implicitly pass `editing_id=None`. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all tests pass. `test_budget_page_renders_notes_inputs` is gone; the two replacement tests (`test_budget_page_renders_note_badge_when_notes_set`, `test_budget_page_omits_note_badge_when_notes_empty`) pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/templates/partials/section.html src/quartermaster/static/app.css src/quartermaster/routes.py tests/test_notes.py +git commit -m "feat(ui): rewrite budget section row for inline edit mode (#21)" +``` + +--- + +## Task 3: Route `GET /entries/{id}/edit` (enter edit mode) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_get_entry_edit_returns_edit_form(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + assert 'class="entry-row editing"' in response.text + assert 'name="name"' in response.text + assert 'name="amount"' in response.text + assert 'name="notes"' in response.text + assert 'value="Twitch"' in response.text + + +def test_get_entry_edit_missing_returns_404(client): + response = client.get("/entries/9999/edit") + assert response.status_code == 404 + + +def test_get_entry_edit_other_rows_stay_in_read_mode(client): + client.post("/sections/subscription/entries", data={"name": "Twitch", "amount": "10.99"}) + client.post("/sections/subscription/entries", data={"name": "Netflix", "amount": "15.49"}) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + # exactly one editing row, one reading row + assert response.text.count('entry-row editing') == 1 + assert response.text.count('entry-row reading') == 1 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py::test_get_entry_edit_returns_edit_form tests/test_routes.py::test_get_entry_edit_missing_returns_404 tests/test_routes.py::test_get_entry_edit_other_rows_stay_in_read_mode -v +``` + +Expected: 404 on the route, or test assertions fail because the route doesn't exist. + +- [ ] **Step 3: Add the `Entry` import** + +In `src/quartermaster/routes.py`, update the imports block near the top of the file: + +```python +from quartermaster.models import SECTION_LABELS, Entry, Section +``` + +- [ ] **Step 4: Implement the route** + +In `src/quartermaster/routes.py`, add after the existing `remove_entry` route: + +```python +@router.get("/entries/{entry_id}/edit", response_class=HTMLResponse) +def edit_entry( + entry_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + entry = db.get(Entry, entry_id) + if entry is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, entry.section, editing_id=entry.id) +``` + +- [ ] **Step 5: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k test_get_entry_edit +``` + +Expected: all 3 pass. + +- [ ] **Step 6: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): add GET /entries/{id}/edit for edit-mode toggle (#21)" +``` + +--- + +## Task 4: Route `POST /entries/{id}` (save edits) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_post_entry_updates_name_and_amount(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch Prime", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 200 + assert "Twitch Prime" in response.text + assert "$11.99" in response.text + # returns to read mode + assert 'class="entry-row reading"' in response.text + # OOB swaps for zero widget and group total + assert 'id="zero-widget"' in response.text + assert 'id="group-total-flexible"' in response.text + + +def test_post_entry_updates_notes_as_badge(client): + client.post( + "/sections/subscription/entries", + data={"name": "Spotify", "amount": "17.48"}, + ) + response = client.post( + "/entries/1", + data={"name": "Spotify", "amount": "17.48", "notes": "family plan"}, + ) + assert response.status_code == 200 + assert "note-badge" in response.text + assert "family plan" in response.text + + +def test_post_entry_debt_minimum_includes_target_oob(client): + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card A", "amount": "50.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Card A", "amount": "60.00", "notes": ""}, + ) + assert response.status_code == 200 + assert 'id="section-debt_target"' in response.text + + +def test_post_entry_empty_name_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": " ", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_negative_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "-1.00", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_non_numeric_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "eleven", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_missing_returns_404(client): + response = client.post( + "/entries/9999", + data={"name": "Whatever", "amount": "1.00", "notes": ""}, + ) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py -v -k "test_post_entry" +``` + +Expected: all fail (route doesn't exist, method not allowed or 404). + +- [ ] **Step 3: Implement the route** + +In `src/quartermaster/routes.py`, add after the `edit_entry` route: + +```python +@router.post("/entries/{entry_id}", response_class=HTMLResponse) +def save_entry( + entry_id: int, + request: Request, + name: str = Form(...), + amount: str = Form(...), + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + clean_name = name.strip() + if not clean_name: + raise HTTPException(status_code=400, detail="name is required") + parsed = _parse_amount(amount) + updated = service.update_entry( + db, entry_id, name=clean_name, amount=parsed, notes=notes + ) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + response = _render_section(request, db, updated.section) + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] + if updated.section == Section.debt_minimum: + extras.append(_render_target(request, db)) + return _append_oob(response, *extras) +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k "test_post_entry" +``` + +Expected: all 7 pass. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): POST /entries/{id} saves edits with OOB totals (#21)" +``` + +--- + +## Task 5: Route `GET /sections/{section}` (cancel / plain re-render) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_get_section_returns_read_mode(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + # enter edit mode first + edit = client.get("/entries/1/edit") + assert 'entry-row editing' in edit.text + # now "cancel" via GET /sections/{section} + response = client.get("/sections/subscription") + assert response.status_code == 200 + assert 'entry-row reading' in response.text + assert 'entry-row editing' not in response.text + + +def test_get_section_invalid_returns_422(client): + # FastAPI rejects an unknown Section enum value at routing + response = client.get("/sections/not_a_real_section") + assert response.status_code == 422 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py -v -k test_get_section +``` + +Expected: 404 or assertion errors (route missing). + +- [ ] **Step 3: Implement the route** + +In `src/quartermaster/routes.py`, add after the `save_entry` route: + +```python +@router.get("/sections/{section}", response_class=HTMLResponse) +def get_section( + section: Section, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + return _render_section(request, db, section) +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k test_get_section +``` + +Expected: both pass. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): GET /sections/{section} for edit-mode cancel (#21)" +``` + +--- + +## Task 6: Remove `POST /entries/{id}/notes` route and migrate its tests + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Migrate the old notes-only route tests** + +In `tests/test_notes.py`, replace: + +```python +def test_update_entry_notes_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post("/entries/1/notes", data={"notes": "weekly"}) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post("/entries/1/notes", data={"notes": ""}) + assert response.status_code == 200 + # the input's value="" still renders but the placeholder kicks in; + # specifically, no literal "weekly" anymore + assert "value=\"weekly\"" not in response.text +``` + +with: + +```python +def test_update_entry_notes_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": ""}, + ) + assert response.status_code == 200 + assert "note-badge" not in response.text + + +def test_old_entry_notes_route_is_removed(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post("/entries/1/notes", data={"notes": "weekly"}) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run to verify the old route test passes and the migrated ones fail** + +``` +uv run pytest tests/test_notes.py -v -k "notes_route or save_route" +``` + +Expected: `test_update_entry_notes_via_save_route` and `test_update_entry_notes_empty_clears_via_save_route` pass (they use the route added in Task 4). `test_old_entry_notes_route_is_removed` FAILS because the route still exists (returns 200). + +- [ ] **Step 3: Remove the route** + +In `src/quartermaster/routes.py`, delete the entire `update_entry_notes` handler (decorator + function). It spans roughly: + +```python +@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) +def update_entry_notes( + entry_id: int, + request: Request, + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + updated = service.set_entry_notes(db, entry_id, notes) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, updated.section) +``` + +- [ ] **Step 4: Run the migrated test to verify it now passes** + +``` +uv run pytest tests/test_notes.py::test_old_entry_notes_route_is_removed -v +``` + +Expected: PASS (route returns 404). + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_notes.py +git commit -m "refactor: remove POST /entries/{id}/notes, superseded by save route (#21)" +``` + +--- + +## Task 7: Remove `service.set_entry_notes` + +**Files:** +- Modify: `src/quartermaster/service.py` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Remove the tests for `set_entry_notes`** + +In `tests/test_notes.py`, delete: + +```python +def test_set_entry_notes_updates(db): + entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) + updated = service.set_entry_notes(db, entry.id, "weekly Costco run") + assert updated is not None + assert updated.notes == "weekly Costco run" + + +def test_set_entry_notes_missing_returns_none(db): + assert service.set_entry_notes(db, 9999, "oops") is None +``` + +These are superseded by `test_update_entry_notes_set_and_clear` and `test_update_entry_missing_returns_none` in `test_service.py` (from Task 1). + +- [ ] **Step 2: Confirm no other caller remains** + +``` +uv run grep -rn "set_entry_notes" src tests +``` + +Expected: no output (empty). If there is any, stop and investigate; do not remove. + +- [ ] **Step 3: Delete the function** + +In `src/quartermaster/service.py`, remove: + +```python +def set_entry_notes( + db: Session, entry_id: int, notes: str | None +) -> Entry | None: + entry = db.get(Entry, entry_id) + if entry is None: + return None + entry.notes = _clean_notes(notes) + db.commit() + db.refresh(entry) + return entry +``` + +- [ ] **Step 4: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +``` +git add src/quartermaster/service.py tests/test_notes.py +git commit -m "refactor(service): remove set_entry_notes, superseded by update_entry (#21)" +``` + +--- + +## Task 8: End-to-end template-edit isolation test + +**Files:** +- Create: `tests/test_template_edit_isolation.py` + +- [ ] **Step 1: Write the failing test (which should already pass)** + +Create `tests/test_template_edit_isolation.py`: + +```python +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.models import Section + + +def test_template_edit_does_not_mutate_existing_month_and_applies_to_next(db): + # seed the template + twitch = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + + # create April 2026 (snapshots the current template) + april = month_service.create_month(db, "2026-04") + april_twitch = next( + e for e in april.entries if e.source_entry_id == twitch.id + ) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # edit the template + service.update_entry( + db, twitch.id, name="Twitch Prime", amount=Decimal("11.99") + ) + + # april is untouched + db.refresh(april_twitch) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # creating May 2026 picks up the new values + may = month_service.create_month(db, "2026-05") + may_twitch = next(e for e in may.entries if e.source_entry_id == twitch.id) + assert may_twitch.planned == Decimal("11.99") + assert may_twitch.origin_planned == Decimal("11.99") + assert may_twitch.name == "Twitch Prime" + assert may_twitch.origin_name == "Twitch Prime" +``` + +- [ ] **Step 2: Run the test** + +``` +uv run pytest tests/test_template_edit_isolation.py -v +``` + +Expected: PASS (all behavior is already in place from Task 1). + +- [ ] **Step 3: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +``` +git add tests/test_template_edit_isolation.py +git commit -m "test: end-to-end template-edit isolation across months (#21)" +``` + +--- + +## Task 9: Manual UI verification + +The feature looks right end-to-end in tests, but UI tests cannot catch visual regressions or HTMX wiring mistakes. Spin up the server and exercise the flow in a browser. + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +``` +uv run uvicorn quartermaster.main:app --reload +``` + +In a separate shell, confirm it answers: + +``` +curl -s http://127.0.0.1:8000/ | head -5 +``` + +- [ ] **Step 2: Manual checklist (open http://127.0.0.1:8000/ in a browser)** + + - [ ] Navigate to Flexible > Subscriptions. Add a temporary entry ("MockSub", "1.00"). + - [ ] Hover the row. Confirm: ✎ and ✕ icons both fade in; row background shifts to `--paper-stripe`. + - [ ] Click ✎. Confirm: row swaps to the edit layout. Name, amount, notes inputs present. Save and Cancel buttons present. No separate notes row below. + - [ ] Edit the name and amount. Click Save. Confirm: row returns to read mode with new values. The section total updates. The "Flexible" group total updates. The zero widget at the top updates. + - [ ] Click ✎ again; edit only the notes field to "hello". Click Save. Confirm: read row now shows the note as an italic badge after the name (`MockSub · hello`). + - [ ] Click ✎; click Cancel. Confirm: row returns to read mode, no values changed. + - [ ] Click ✎ on MockSub; click ✎ on a different subscription. Confirm: only the most recent row is in edit mode, the first reverts to read mode. + - [ ] Add a debt-minimum entry ("MockCard", "25.00"). Edit its amount to "30.00" via the ✎ flow. Confirm: the Primary Debt Target card (if set to this card) updates its displayed amount. + - [ ] Edit an existing subscription entry that is already snapshotted into a month. Open the month view. Confirm: the month's `planned` for that entry is unchanged; no "modified" badge appears. + - [ ] Create a new month *after* the template edit. Confirm: the new month's snapshot reflects the edited values. + - [ ] Delete the temporary MockSub and MockCard entries. + +- [ ] **Step 3: Stop the dev server** + +Ctrl+C the uvicorn process. + +- [ ] **Step 4 (if any visual fix was needed): Commit** + +If the manual pass revealed a CSS or template tweak, commit it: + +``` +git add ... +git commit -m "fix(ui): (#21)" +``` + +--- + +## Task 10: Open the pull request + +**Files:** none + +- [ ] **Step 1: Push the latest commits** + +``` +git push +``` + +- [ ] **Step 2: Open the PR via the gitea MCP** + +Use `mcp__gitea__pull_request_write method=create` with: +- owner: `archeious` +- repo: `quartermaster` +- base: `main` +- head: `feat/21-edit-template-entries` +- title: `Edit name/amount on budget template entries (#21)` +- body: link to the spec path and to issue #21, short summary of what changed (new save route, new edit/cancel routes, rewritten section template, removed notes-only route and service function, end-to-end isolation test). + +- [ ] **Step 3: Report PR number and URL back to the user.** + +Do not merge. The user merges manually after review (per repo workflow: "Merge via the Forgejo API/UI, not locally").