quartermaster/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md
archeious ab5b88a52b 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 18:42:09 -06:00

38 KiB

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:

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):

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

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:

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:

<section class="section" id="section-{{ section.section.value }}">
  <div class="section-header">
    <h2>{{ section.label }}</h2>
    <span class="total" data-testid="total-{{ section.section.value }}">
      ${{ '{:,.2f}'.format(section.total) }}
    </span>
  </div>
  <div class="entries budget-entries">
    {% for entry in section.entries %}
      {% if editing_id is not none and entry.id == editing_id %}
        <form
          class="entry-row editing"
          hx-post="/entries/{{ entry.id }}"
          hx-target="#section-{{ section.section.value }}"
          hx-swap="outerHTML"
        >
          <input
            class="name-input"
            type="text"
            name="name"
            value="{{ entry.name }}"
            required
            aria-label="Name"
          >
          <input
            class="amount-input"
            type="number"
            step="0.01"
            min="0"
            name="amount"
            value="{{ '%.2f' | format(entry.amount) }}"
            required
            aria-label="Amount"
          >
          <input
            class="notes-input"
            type="text"
            name="notes"
            value="{{ entry.notes or '' }}"
            placeholder="notes (optional)"
            aria-label="Notes"
          >
          <div class="entry-actions">
            <button type="submit" class="save-btn">Save</button>
            <button
              type="button"
              class="cancel-btn"
              hx-get="/sections/{{ section.section.value }}"
              hx-target="#section-{{ section.section.value }}"
              hx-swap="outerHTML"
            >Cancel</button>
          </div>
        </form>
      {% else %}
        <div class="entry-row reading">
          <span class="entry-name">
            {{ entry.name }}{% if entry.notes %}<span class="note-badge">{{ entry.notes }}</span>{% endif %}
          </span>
          <span class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</span>
          <div class="entry-actions">
            <button
              class="edit"
              type="button"
              hx-get="/entries/{{ entry.id }}/edit"
              hx-target="#section-{{ section.section.value }}"
              hx-swap="outerHTML"
              aria-label="Edit {{ entry.name }}"
            >&#x270E;</button>
            <button
              class="delete"
              type="button"
              hx-delete="/entries/{{ entry.id }}"
              hx-target="#section-{{ section.section.value }}"
              hx-swap="outerHTML"
              aria-label="Delete {{ entry.name }}"
            >&times;</button>
          </div>
        </div>
      {% endif %}
    {% else %}
      <div class="empty-row">No entries yet.</div>
    {% endfor %}
    <div class="add-row">
      <details class="add-entry">
        <summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
        <form
          class="add-form"
          hx-post="/sections/{{ section.section.value }}/entries"
          hx-target="#section-{{ section.section.value }}"
          hx-swap="outerHTML"
          hx-on::after-request="if(event.detail.successful) this.reset()"
        >
          <input type="text" name="name" placeholder="Name" required>
          <input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
          <button type="submit">Add</button>
          <input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
        </form>
      </details>
    </div>
  </div>
</section>
  • 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:

/* =============== 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):

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:

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:

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:

@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:

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:

@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:

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:

@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:

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:

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:

@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:

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:

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:

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): <what was adjusted> (#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").