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