1257 lines
38 KiB
Markdown
1257 lines
38 KiB
Markdown
|
|
# 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 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 }}"
|
||
|
|
>✎</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 }}"
|
||
|
|
>×</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:
|
||
|
|
|
||
|
|
```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): <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").
|