From aa7ebaa234ea857d488306742825129a6a83b87e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:37:10 -0600 Subject: [PATCH 01/12] docs: spec for editing budget template entries (#21) Adds the brainstorm-phase design for inline name/amount/notes edit on the budget template page. Layout variant A (swap in place), notes folded into edit mode, no schema change, forward-facing only. Also adds .superpowers/ to .gitignore so the brainstorm companion's working dir does not end up tracked. --- .gitignore | 3 + ...-17-edit-budget-template-entries-design.md | 290 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md diff --git a/.gitignore b/.gitignore index 0e14640..72eebfa 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Superpowers brainstorm companion working dir +.superpowers/ + diff --git a/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md b/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md new file mode 100644 index 0000000..b38d6c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md @@ -0,0 +1,290 @@ +# Edit budget template entries (name and amount) + +## Problem + +The budget template page (index, `partials/section.html`) lets a user add +an entry, delete an entry, and edit the notes on an entry. It does not +let a user edit the **name** or **amount** of an existing entry. If a +subscription price changes (e.g. Twitch goes from $10.99 to $11.99), the +only workaround is delete + re-add, which also orphans its `Entry.id` +and breaks the historical link from existing `MonthEntry.source_entry_id` +rows. + +## Goal + +Add inline edit for name, amount, and notes on a budget template entry. +The change must be **forward-facing only**: existing `Month` rows +(planning, active, closed) keep their snapshotted `MonthEntry.planned`, +`MonthEntry.name`, and deviation baseline (`origin_name`, +`origin_planned`) untouched. Only months created **after** the edit see +the new values, via the existing `create_month` snapshot flow. + +## Non-goals + +- No schema change, no migration, no backfill. +- No bulk-apply-to-existing-months affordance. The user explicitly does + not want retroactive edits. +- No change to the month-view inline-edit UX. +- No change to the add-entry flow. + +## Architecture + +The feature is essentially already implemented at the data layer. The +snapshot-over-mirror model guarantees that `Entry` is read **only** by +`create_month` (verified in `month_service.create_month`, where +`budget_entries` is the single read site for the purpose of building +`MonthEntry` rows). All other reads of planned amounts and names go +through `MonthEntry` directly. + +Consequently: + +- Updating `Entry.name` or `Entry.amount` has no effect on any existing + `Month` or `MonthEntry`. +- `MonthEntry.origin_name` and `MonthEntry.origin_planned` remain at + their original snapshot values, so deviation tagging (`modified` / + `new this month`) continues to compare each month's rows against that + month's baseline, not against the live template. This is the correct + behavior and the design intentionally preserves it. + +The work is therefore purely a UI + service + route addition with no +data-model changes. + +## UI behavior + +Layout **variant A, "swap in place"**, chosen over alternatives B +(expanding drawer) and C (always-editable) for its horizontal compactness +and the clearer read/edit mode separation. Full mockups in +`.superpowers/brainstorm/.../layout-options.html`. + +### Read row (default) + +Single line: + +``` +[name] [$amount] ✎ ✕ +``` + +- If `entry.notes` is set, render a small italic badge after the name + (e.g. `Spotify · family plan`). If notes is empty or null, render + nothing for notes (no placeholder row, no empty affordance). +- The edit (✎) and delete (✕) icons use the existing + `.entry-actions button.delete` hover-fade pattern, extended to the new + edit button. +- The existing separate notes row (`tr.entry-notes-row`) is removed. + +### Edit row (swaps into the same row slot) + +Single line: + +``` +[name input] [amount input] [notes input] Save Cancel +``` + +- Name, amount, and notes inputs use the **bordered-padded** form style + already defined for `.add-form input` / `.month-add-form input` + (bordered, padded, `--rule` border, `--paper-soft` background, + border darkens to `--ink` on focus). This matches the mockup the user + approved (variant A) and makes edit mode visually distinct from the + underline-style always-editable inputs on the month view. The month + view's underline style is appropriate for fields that are always + editable; this feature uses a discrete edit mode, so bordered inputs + are the correct visual language. +- Notes input is always present while editing, styled italic muted and + using the same bordered style. No separate "+note" affordance. +- Save commits the edit atomically (all three fields in one request); + Cancel discards and returns to read mode. +- Grid columns shift when in edit mode: name narrower, amount unchanged + size, notes absorbs the rest of the row, followed by the two buttons. + +### Toggle mechanism + +Server-rendered, HTMX-driven. No client-side state; matches every other +interaction on the index page. + +- Click ✎: `GET /entries/{id}/edit` returns the `#section-{section}` + partial with `editing_id` set to `{id}`. Swap target: + `#section-{section}`, `outerHTML`. +- Click Save: `POST /entries/{id}` with form fields `name`, `amount`, + `notes`. Updates the `Entry` row, then returns the section partial in + read mode. Plus OOB partials for `#zero-widget` (via + `partials/budget_zero.html`) and each `#group-total-{group}` span + (via `partials/budget_group_totals.html`) because the amount change + affects both; plus the target card (OOB `#section-debt_target`) if + the entry's section is `debt_minimum`. These are the exact + OOB-swap targets already used by `create_entry` and `remove_entry` + in `routes.py`; no new OOB wiring is needed. +- Click Cancel: `GET /sections/{section}` returns the section partial + in plain read mode (no `editing_id`). Same swap target. No server + state to unwind. + +### Concurrent edits within a section + +Only one row can be in edit mode at a time, because the section is +re-rendered from scratch on each edit-mode transition. If the user +clicks ✎ on row A, then clicks ✎ on row B, the server renders the +section with row B editing and row A back in read mode. No extra logic +required; this falls out of the stateless swap pattern. + +## Backend API + +### `service.update_entry` + +New function in `src/quartermaster/service.py`: + +```python +_NOTES_SENTINEL = object() + +def update_entry( + db: Session, + entry_id: int, + *, + name: str | None = None, + amount: Decimal | None = None, + notes: str | None | object = _NOTES_SENTINEL, +) -> Entry | None: + """Update an Entry's name, amount, or notes. + + Mirrors month_service.update_month_entry in shape. + Returns the updated Entry, or None if not found. + """ +``` + +Semantics: + +- `name=None` and `amount=None` mean "do not change this field". +- `notes=_NOTES_SENTINEL` means "do not change"; `notes=None` or + `notes=""` clear the notes to null (via the existing `_clean_notes`). +- Name, when provided, must be non-empty after strip; caller validates. +- Amount validation (non-negative, decimal) is the caller's + responsibility; the existing `_parse_amount` helper handles this at + the route layer. + +The existing `set_entry_notes` becomes a thin wrapper around +`update_entry(db, entry_id, notes=notes)`, or is removed and its one +route caller migrated directly. Preference: remove, migrate the caller. +The template's notes-only row is being removed anyway, so the +dedicated route is no longer needed. + +### Routes + +Three new / changed routes in `src/quartermaster/routes.py`: + +| Method | Path | Purpose | Response | +|--------|--------------------------|-------------------------|---------------------------------------------------------------------| +| `GET` | `/entries/{id}/edit` | Enter edit mode | `partials/section.html` with `editing_id={id}` | +| `POST` | `/entries/{id}` | Save edits | Section read-mode + OOB zero + OOB group totals (+ OOB target card if section is `debt_minimum`) | +| `GET` | `/sections/{section}` | Cancel / plain re-render| `partials/section.html` read mode | + +Form fields on `POST /entries/{id}`: + +- `name`: required, non-empty after strip. 400 if empty. +- `amount`: required, parseable Decimal, non-negative. 400 on parse + failure or negative. +- `notes`: optional, passed through `_clean_notes`. + +404 returned from any of the three if `entry_id` does not resolve to an +`Entry`. + +The existing `POST /entries/{entry_id}/notes` route and its UI row are +removed. Notes are now edited only through the edit-mode flow. + +### Template change + +`src/quartermaster/templates/partials/section.html` gains an +`editing_id` context variable (default `None`). For each entry: + +- If `entry.id == editing_id`: render the edit row (name input, amount + input, notes input, Save, Cancel). +- Else: render the read row (name with optional notes badge, amount, + edit button, delete button). + +The separate `tr.entry-notes-row` block is deleted. + +The existing `_render_section` helper in `routes.py` gains an optional +`editing_id: int | None = None` argument and threads it into the +template context. All existing callers (`create_entry`, `remove_entry`, +the new `POST /entries/{id}` save, the new `GET /sections/{section}` +cancel) pass the default `None`. Only the new `GET /entries/{id}/edit` +handler passes a concrete id. In the template, the variable is +referenced via `{% if editing_id is not none and entry.id == editing_id %}` +so rendering is defensive if the variable is ever missing. + +## CSS changes + +In `src/quartermaster/static/app.css`: + +- New grid template for `tr.entry` (read mode): drop the current + third "empty" column, collapse to `minmax(0, 1fr) 5.5rem auto` + (name / amount / actions). The actions cell holds both ✎ and ✕. +- New grid template for `tr.entry.editing` (edit mode): + `minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto` (name input / amount + input / notes input / buttons). +- Styles for the edit icon mirroring the existing delete icon's + hover-fade behavior. +- Styles for the Save / Cancel buttons matching the existing + `.lifecycle-form button` treatment. +- `.note-badge` for the inline notes badge on read-mode rows. +- Delete the `tr.entry-notes-row` rules (no longer used). + +## Testing + +### `tests/test_service.py`, new tests for `update_entry` + +- Updates name only; amount and notes unchanged. +- Updates amount only; name and notes unchanged. +- Updates notes: set from null to text, set from text to different + text, clear with empty string, clear with `None`. +- Updates all three atomically. +- Returns `None` when `entry_id` does not exist. +- **Forward-facing invariant, direct DB assertion:** create a month + (which snapshots the entry), call `update_entry` with new name and + amount, assert the month's `MonthEntry.name`, `.planned`, + `.origin_name`, and `.origin_planned` are all unchanged. + +### `tests/test_routes.py`, new tests for the edit flow + +- `GET /entries/{id}/edit` returns 200; response body contains inputs + with `name="name"` and `name="amount"`; does not contain the entry's + current name rendered as read-mode text. +- `POST /entries/{id}` with valid name + amount + notes returns 200; + response contains the new values rendered in read mode; response + contains OOB swaps targeting `#budget-zero` and + `#budget-group-totals`. +- `POST /entries/{id}` for a `debt_minimum`-section entry additionally + includes an OOB target card. +- `POST /entries/{id}` with blank name returns 400. +- `POST /entries/{id}` with negative amount returns 400. +- `POST /entries/{id}` with non-numeric amount returns 400. +- `POST /entries/{id}` for a missing id returns 404. +- `GET /entries/{id}/edit` for a missing id returns 404. +- `GET /sections/{section}` returns 200 in plain read mode. +- The legacy `POST /entries/{id}/notes` route returns 404 (removed). + +### `tests/test_month_lifecycle.py` (or a new `test_template_edit_isolation.py`) + +Headline end-to-end test: + +1. Seed an `Entry` (e.g. Twitch, $10.99). +2. Create Month `2026-04`. +3. Assert the month's `MonthEntry` for Twitch has `planned = 10.99`. +4. Call `service.update_entry` to set Twitch to `11.99`. +5. Assert the month's `MonthEntry` is still `10.99` and its `origin_planned` is still `10.99`. +6. Create Month `2026-05`. +7. Assert the new month's `MonthEntry` for Twitch has `planned = 11.99`. + +This test proves the user-facing guarantee end-to-end with no +mocking. + +## Out of scope / future work + +- "Apply this change to the current active month too" is an explicit + non-goal, and we believe the right workflow for that case is to edit + the month-entry directly on the month view (which already works). +- Bulk edit, CSV import, undo, or edit history are all out of scope. + +## Issue + +Issue-driven work: an open Forgejo issue for this feature needs to be +created before implementation begins. Suggested title: "Edit +name/amount on budget template entries (forward-only)". Issue body +should link to this spec. -- 2.45.2 From ab5b88a52bb72872a44b92d471eb6cd6d7b6f51c Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:42:09 -0600 Subject: [PATCH 02/12] docs: implementation plan for editing template entries (#21) Ten-task TDD plan: service.update_entry, three new routes, template rewrite, CSS, test migration for the removed notes route, end-to-end isolation test, manual UI verification, and PR opening. --- ...2026-04-17-edit-budget-template-entries.md | 1256 +++++++++++++++++ 1 file changed, 1256 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md diff --git a/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md b/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md new file mode 100644 index 0000000..1d5c091 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md @@ -0,0 +1,1256 @@ +# Edit budget template entries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add inline edit for name, amount, and notes on budget template entries. Changes are forward-facing only: existing `Month` snapshots are untouched, only newly created months pick up the edited values. + +**Architecture:** Snapshot-over-mirror already guarantees isolation at the data layer. This plan adds a `service.update_entry` function, three new HTMX routes (`GET /entries/{id}/edit`, `POST /entries/{id}`, `GET /sections/{section}`), a rewritten `partials/section.html` with a read-row / edit-row toggle driven by an `editing_id` template variable, and matching CSS. The old notes-only route and its separate notes row are removed. + +**Tech Stack:** FastAPI, SQLAlchemy, Jinja2 templates, HTMX, pytest. + +**Spec:** `docs/superpowers/specs/2026-04-17-edit-budget-template-entries-design.md` + +**Issue:** #21 ("Edit name/amount on budget template entries (forward-only)") + +**Branch:** `feat/21-edit-template-entries` (already created, spec already committed) + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/quartermaster/service.py` | Modify | Add `update_entry`; remove `set_entry_notes` (migrated) | +| `src/quartermaster/routes.py` | Modify | Add `GET /entries/{id}/edit`, `POST /entries/{id}`, `GET /sections/{section}`; remove `POST /entries/{id}/notes`; thread `editing_id` through `_render_section` | +| `src/quartermaster/templates/partials/section.html` | Modify | Rewrite: read row with optional note badge, edit row with inputs + Save/Cancel, driven by `editing_id`; remove separate notes row | +| `src/quartermaster/static/app.css` | Modify | New grid for read row (no empty column); new edit-row layout and styling; note-badge style; remove `.entry-notes-row` rules | +| `tests/test_service.py` | Modify | Unit tests for `update_entry` | +| `tests/test_routes.py` | Modify | Route tests for the three new endpoints | +| `tests/test_notes.py` | Modify | Migrate tests that reference removed `set_entry_notes`, removed `POST /entries/{id}/notes` route, removed `entry-notes-row` class | +| `tests/test_template_edit_isolation.py` | Create | End-to-end test: edit template, assert existing month unchanged, assert new month sees edit | + +## Test commands reference + +Run a single test: +``` +uv run pytest tests/test_service.py::test_name -v +``` + +Run the full suite: +``` +uv run pytest -q +``` + +--- + +## Task 1: Service `update_entry` function + +**Files:** +- Modify: `src/quartermaster/service.py` +- Modify: `tests/test_service.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_service.py`: + +```python +def test_update_entry_name_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, name="Twitch Prime") + assert updated is not None + assert updated.name == "Twitch Prime" + assert updated.amount == Decimal("10.99") + + +def test_update_entry_amount_only(db): + entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99")) + updated = service.update_entry(db, entry.id, amount=Decimal("11.99")) + assert updated is not None + assert updated.name == "Twitch" + assert updated.amount == Decimal("11.99") + + +def test_update_entry_notes_set_and_clear(db): + entry = service.add_entry(db, Section.other, "Parking", Decimal("25.00")) + updated = service.update_entry(db, entry.id, notes="work") + assert updated is not None + assert updated.notes == "work" + updated = service.update_entry(db, entry.id, notes="") + assert updated is not None + assert updated.notes is None + service.update_entry(db, entry.id, notes="work again") + updated = service.update_entry(db, entry.id, notes=None) + assert updated is not None + assert updated.notes is None + + +def test_update_entry_all_three_atomic(db): + entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) + updated = service.update_entry( + db, + entry.id, + name="Groceries (Costco)", + amount=Decimal("450.00"), + notes="weekly run", + ) + assert updated is not None + assert updated.name == "Groceries (Costco)" + assert updated.amount == Decimal("450.00") + assert updated.notes == "weekly run" + + +def test_update_entry_notes_untouched_when_sentinel(db): + entry = service.add_entry( + db, Section.other, "Gift", Decimal("25.00"), notes="birthday" + ) + updated = service.update_entry(db, entry.id, amount=Decimal("30.00")) + assert updated is not None + assert updated.notes == "birthday" + + +def test_update_entry_missing_returns_none(db): + assert service.update_entry(db, 9999, name="Whatever") is None + + +def test_update_entry_does_not_mutate_existing_month_snapshot(db): + from quartermaster import month_service + entry = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + month = month_service.create_month(db, "2026-04") + me = next(e for e in month.entries if e.source_entry_id == entry.id) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" + + service.update_entry( + db, entry.id, name="Twitch Prime", amount=Decimal("11.99") + ) + db.refresh(me) + assert me.planned == Decimal("10.99") + assert me.origin_planned == Decimal("10.99") + assert me.name == "Twitch" + assert me.origin_name == "Twitch" +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_service.py::test_update_entry_name_only tests/test_service.py::test_update_entry_amount_only tests/test_service.py::test_update_entry_notes_set_and_clear tests/test_service.py::test_update_entry_all_three_atomic tests/test_service.py::test_update_entry_notes_untouched_when_sentinel tests/test_service.py::test_update_entry_missing_returns_none tests/test_service.py::test_update_entry_does_not_mutate_existing_month_snapshot -v +``` + +Expected: all fail with `AttributeError: module 'quartermaster.service' has no attribute 'update_entry'`. + +- [ ] **Step 3: Implement `update_entry`** + +In `src/quartermaster/service.py`, add a module-level sentinel (below the imports) and the function (after `set_entry_notes`, before `delete_entry`): + +```python +_NOTES_SENTINEL = object() + + +def update_entry( + db: Session, + entry_id: int, + *, + name: str | None = None, + amount: Decimal | None = None, + notes: str | None | object = _NOTES_SENTINEL, +) -> Entry | None: + entry = db.get(Entry, entry_id) + if entry is None: + return None + if name is not None: + entry.name = name.strip() + if amount is not None: + entry.amount = amount + if notes is not _NOTES_SENTINEL: + entry.notes = _clean_notes(notes) # type: ignore[arg-type] + db.commit() + db.refresh(entry) + return entry +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_service.py -v -k update_entry +``` + +Expected: all 7 `update_entry` tests pass. + +- [ ] **Step 5: Run the full suite to confirm no regression** + +``` +uv run pytest -q +``` + +Expected: 117 + 7 = 124 passing. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/service.py tests/test_service.py +git commit -m "feat(service): add update_entry for template rows (#21)" +``` + +--- + +## Task 2: Rewrite `partials/section.html` and CSS + +This task changes the template structure and styling. Some existing tests in `test_notes.py` assert the old markup (`entry-notes-row` class, `value="weekly"` inside a notes input on the read-mode page). Those tests get migrated in this same task so the suite stays green. + +**Files:** +- Modify: `src/quartermaster/templates/partials/section.html` +- Modify: `src/quartermaster/static/app.css` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Migrate `test_notes.py` assertions to new markup (tests first)** + +Replace the following tests in `tests/test_notes.py`: + +```python +def test_budget_page_renders_notes_inputs(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "entry-notes-row" in response.text + assert "due 1st" in response.text +``` + +with: + +```python +def test_budget_page_renders_note_badge_when_notes_set(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "note-badge" in response.text + assert "due 1st" in response.text + + +def test_budget_page_omits_note_badge_when_notes_empty(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "note-badge" not in response.text +``` + +Leave the rest of `test_notes.py` alone for now (the `POST /entries/{id}/notes` route tests will be migrated in Task 6 when the route is removed). + +- [ ] **Step 2: Rewrite `partials/section.html`** + +Replace the entire file with: + +```html +
+
+

{{ section.label }}

+ + ${{ '{:,.2f}'.format(section.total) }} + +
+
+ {% for entry in section.entries %} + {% if editing_id is not none and entry.id == editing_id %} +
+ + + +
+ + +
+
+ {% else %} +
+ + {{ entry.name }}{% if entry.notes %}{{ entry.notes }}{% endif %} + + ${{ '{:,.2f}'.format(entry.amount) }} +
+ + +
+
+ {% endif %} + {% else %} +
No entries yet.
+ {% endfor %} +
+
+ + add {{ section.label|lower }} +
+ + + + +
+
+
+
+
+``` + +- [ ] **Step 3: Update CSS in `src/quartermaster/static/app.css`** + +Replace the entire `/* =============== ENTRY TABLE =============== */` block and the `/* Notes row */` block (roughly lines 364 through 563) with the new layout. Use this as the replacement block: + +```css +/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */ + +.budget-entries { display: flex; flex-direction: column; } + +.entry-row.reading { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem auto; + gap: 0.6rem; + align-items: baseline; + padding: 0.26rem 0.25rem 0.28rem; + border-bottom: 1px dotted var(--rule); + position: relative; +} +.entry-row.reading:hover { background: var(--paper-stripe); } + +.entry-row.reading .entry-name { + font-family: var(--sans); + font-weight: 500; + font-size: 1.02rem; + color: var(--ink); + letter-spacing: 0.01em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.entry-row.reading .note-badge { + font-family: var(--sans); + font-style: italic; + font-size: 0.82rem; + color: var(--muted); + margin-left: 0.5rem; + letter-spacing: 0.02em; + opacity: 0.85; +} +.entry-row.reading .note-badge::before { + content: "· "; + color: var(--rule); + font-style: normal; +} +.entry-row.reading .entry-amount { + font-family: var(--sans); + font-weight: 500; + font-size: 1rem; + color: var(--ink); + font-feature-settings: "lnum" 1, "tnum" 1; + text-align: right; + min-width: 0; +} + +.entry-row.reading .entry-actions { + display: flex; + gap: 0.3rem; + align-items: center; +} +.entry-row.reading .entry-actions button { + background: none; + border: none; + cursor: pointer; + padding: 0; + line-height: 1; + color: var(--rule); + opacity: 0; + transition: color 0.12s ease, opacity 0.12s ease; +} +.entry-row.reading:hover .entry-actions button { opacity: 1; } +.entry-row.reading .entry-actions button.edit { + font-size: 0.95rem; + color: var(--rule); +} +.entry-row.reading .entry-actions button.edit:hover { color: var(--ink); } +.entry-row.reading .entry-actions button.delete { + font-size: 1.2rem; + font-weight: 400; +} +.entry-row.reading .entry-actions button.delete:hover { color: var(--accent); } + +/* Edit row: form that replaces the reading row in place */ +.entry-row.editing { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto; + gap: 0.5rem; + align-items: center; + padding: 0.26rem 0.25rem 0.28rem; + border-bottom: 1px dotted var(--rule); + background: var(--paper-soft); + margin: 0; +} +.entry-row.editing input { + font-family: var(--sans); + font-size: 0.95rem; + padding: 0.2rem 0.4rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + outline: none; + transition: border-color 0.12s; + min-width: 0; +} +.entry-row.editing input:focus { border-color: var(--ink); } +.entry-row.editing input[type="number"] { + text-align: right; + font-variant-numeric: tabular-nums; +} +.entry-row.editing .notes-input { + font-style: italic; + font-size: 0.88rem; + color: var(--muted); +} +.entry-row.editing .entry-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} +.entry-row.editing .save-btn, +.entry-row.editing .cancel-btn { + font-family: var(--sans); + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.22rem 0.6rem; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} +.entry-row.editing .save-btn { + border: 1px solid var(--ink); + background: var(--paper-soft); + color: var(--ink); +} +.entry-row.editing .save-btn:hover { + background: var(--sage); + color: var(--paper); + border-color: var(--sage); +} +.entry-row.editing .cancel-btn { + border: 1px solid var(--rule); + background: transparent; + color: var(--muted); +} +.entry-row.editing .cancel-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.empty-row { + padding: 0.4rem 0.5rem; + color: var(--muted); + font-style: italic; + font-size: 0.9rem; +} + +.add-row { + padding: 0.4rem 0.25rem 0.2rem; + grid-column: 1 / -1; +} + +@media (max-width: 520px) { + .entry-row.reading { + grid-template-columns: minmax(0, 1fr) 4.6rem auto; + gap: 0.4rem; + } + .entry-row.editing { + grid-template-columns: minmax(0, 1fr) 4.6rem; + gap: 0.4rem; + } + .entry-row.editing .notes-input, + .entry-row.editing .entry-actions { grid-column: 1 / -1; } +} +``` + +Remove the obsolete `table.entries`, `tr.entry`, `tr.entry-notes-row`, and `tr.empty`, `tr.add-row` rules in the old block. Keep the `details.add-entry` rules (add-entry disclosure still works). Keep the `.tag` rules. Keep the `.section.target-section` rules. Keep the `.add-form` rules. Keep everything below `=============== PRIMARY DEBT TARGET ===============` onward. + +- [ ] **Step 4: Thread `editing_id=None` default into `_render_section`** + +In `src/quartermaster/routes.py`, update `_render_section` (around line 41): + +```python +def _render_section( + request: Request, + db: Session, + section: Section, + editing_id: int | None = None, +) -> HTMLResponse: + view = _section_view(db, section) + return templates.TemplateResponse( + request, + "partials/section.html", + {"section": view, "editing_id": editing_id}, + ) +``` + +Existing callers (`create_entry`, `remove_entry`, `update_entry_notes`) keep using the positional form; they implicitly pass `editing_id=None`. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all tests pass. `test_budget_page_renders_notes_inputs` is gone; the two replacement tests (`test_budget_page_renders_note_badge_when_notes_set`, `test_budget_page_omits_note_badge_when_notes_empty`) pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/templates/partials/section.html src/quartermaster/static/app.css src/quartermaster/routes.py tests/test_notes.py +git commit -m "feat(ui): rewrite budget section row for inline edit mode (#21)" +``` + +--- + +## Task 3: Route `GET /entries/{id}/edit` (enter edit mode) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_get_entry_edit_returns_edit_form(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + assert 'class="entry-row editing"' in response.text + assert 'name="name"' in response.text + assert 'name="amount"' in response.text + assert 'name="notes"' in response.text + assert 'value="Twitch"' in response.text + + +def test_get_entry_edit_missing_returns_404(client): + response = client.get("/entries/9999/edit") + assert response.status_code == 404 + + +def test_get_entry_edit_other_rows_stay_in_read_mode(client): + client.post("/sections/subscription/entries", data={"name": "Twitch", "amount": "10.99"}) + client.post("/sections/subscription/entries", data={"name": "Netflix", "amount": "15.49"}) + response = client.get("/entries/1/edit") + assert response.status_code == 200 + # exactly one editing row, one reading row + assert response.text.count('entry-row editing') == 1 + assert response.text.count('entry-row reading') == 1 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py::test_get_entry_edit_returns_edit_form tests/test_routes.py::test_get_entry_edit_missing_returns_404 tests/test_routes.py::test_get_entry_edit_other_rows_stay_in_read_mode -v +``` + +Expected: 404 on the route, or test assertions fail because the route doesn't exist. + +- [ ] **Step 3: Add the `Entry` import** + +In `src/quartermaster/routes.py`, update the imports block near the top of the file: + +```python +from quartermaster.models import SECTION_LABELS, Entry, Section +``` + +- [ ] **Step 4: Implement the route** + +In `src/quartermaster/routes.py`, add after the existing `remove_entry` route: + +```python +@router.get("/entries/{entry_id}/edit", response_class=HTMLResponse) +def edit_entry( + entry_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + entry = db.get(Entry, entry_id) + if entry is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, entry.section, editing_id=entry.id) +``` + +- [ ] **Step 5: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k test_get_entry_edit +``` + +Expected: all 3 pass. + +- [ ] **Step 6: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): add GET /entries/{id}/edit for edit-mode toggle (#21)" +``` + +--- + +## Task 4: Route `POST /entries/{id}` (save edits) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_post_entry_updates_name_and_amount(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch Prime", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 200 + assert "Twitch Prime" in response.text + assert "$11.99" in response.text + # returns to read mode + assert 'class="entry-row reading"' in response.text + # OOB swaps for zero widget and group total + assert 'id="zero-widget"' in response.text + assert 'id="group-total-flexible"' in response.text + + +def test_post_entry_updates_notes_as_badge(client): + client.post( + "/sections/subscription/entries", + data={"name": "Spotify", "amount": "17.48"}, + ) + response = client.post( + "/entries/1", + data={"name": "Spotify", "amount": "17.48", "notes": "family plan"}, + ) + assert response.status_code == 200 + assert "note-badge" in response.text + assert "family plan" in response.text + + +def test_post_entry_debt_minimum_includes_target_oob(client): + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card A", "amount": "50.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Card A", "amount": "60.00", "notes": ""}, + ) + assert response.status_code == 200 + assert 'id="section-debt_target"' in response.text + + +def test_post_entry_empty_name_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": " ", "amount": "11.99", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_negative_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "-1.00", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_non_numeric_amount_returns_400(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + response = client.post( + "/entries/1", + data={"name": "Twitch", "amount": "eleven", "notes": ""}, + ) + assert response.status_code == 400 + + +def test_post_entry_missing_returns_404(client): + response = client.post( + "/entries/9999", + data={"name": "Whatever", "amount": "1.00", "notes": ""}, + ) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py -v -k "test_post_entry" +``` + +Expected: all fail (route doesn't exist, method not allowed or 404). + +- [ ] **Step 3: Implement the route** + +In `src/quartermaster/routes.py`, add after the `edit_entry` route: + +```python +@router.post("/entries/{entry_id}", response_class=HTMLResponse) +def save_entry( + entry_id: int, + request: Request, + name: str = Form(...), + amount: str = Form(...), + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + clean_name = name.strip() + if not clean_name: + raise HTTPException(status_code=400, detail="name is required") + parsed = _parse_amount(amount) + updated = service.update_entry( + db, entry_id, name=clean_name, amount=parsed, notes=notes + ) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + response = _render_section(request, db, updated.section) + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] + if updated.section == Section.debt_minimum: + extras.append(_render_target(request, db)) + return _append_oob(response, *extras) +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k "test_post_entry" +``` + +Expected: all 7 pass. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): POST /entries/{id} saves edits with OOB totals (#21)" +``` + +--- + +## Task 5: Route `GET /sections/{section}` (cancel / plain re-render) + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_routes.py`: + +```python +def test_get_section_returns_read_mode(client): + client.post( + "/sections/subscription/entries", + data={"name": "Twitch", "amount": "10.99"}, + ) + # enter edit mode first + edit = client.get("/entries/1/edit") + assert 'entry-row editing' in edit.text + # now "cancel" via GET /sections/{section} + response = client.get("/sections/subscription") + assert response.status_code == 200 + assert 'entry-row reading' in response.text + assert 'entry-row editing' not in response.text + + +def test_get_section_invalid_returns_422(client): + # FastAPI rejects an unknown Section enum value at routing + response = client.get("/sections/not_a_real_section") + assert response.status_code == 422 +``` + +- [ ] **Step 2: Run to verify the tests fail** + +``` +uv run pytest tests/test_routes.py -v -k test_get_section +``` + +Expected: 404 or assertion errors (route missing). + +- [ ] **Step 3: Implement the route** + +In `src/quartermaster/routes.py`, add after the `save_entry` route: + +```python +@router.get("/sections/{section}", response_class=HTMLResponse) +def get_section( + section: Section, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + return _render_section(request, db, section) +``` + +- [ ] **Step 4: Run the new tests to verify they pass** + +``` +uv run pytest tests/test_routes.py -v -k test_get_section +``` + +Expected: both pass. + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_routes.py +git commit -m "feat(routes): GET /sections/{section} for edit-mode cancel (#21)" +``` + +--- + +## Task 6: Remove `POST /entries/{id}/notes` route and migrate its tests + +**Files:** +- Modify: `src/quartermaster/routes.py` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Migrate the old notes-only route tests** + +In `tests/test_notes.py`, replace: + +```python +def test_update_entry_notes_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post("/entries/1/notes", data={"notes": "weekly"}) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post("/entries/1/notes", data={"notes": ""}) + assert response.status_code == 200 + # the input's value="" still renders but the placeholder kicks in; + # specifically, no literal "weekly" anymore + assert "value=\"weekly\"" not in response.text +``` + +with: + +```python +def test_update_entry_notes_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears_via_save_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post( + "/entries/1", + data={"name": "Groceries", "amount": "400.00", "notes": ""}, + ) + assert response.status_code == 200 + assert "note-badge" not in response.text + + +def test_old_entry_notes_route_is_removed(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post("/entries/1/notes", data={"notes": "weekly"}) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run to verify the old route test passes and the migrated ones fail** + +``` +uv run pytest tests/test_notes.py -v -k "notes_route or save_route" +``` + +Expected: `test_update_entry_notes_via_save_route` and `test_update_entry_notes_empty_clears_via_save_route` pass (they use the route added in Task 4). `test_old_entry_notes_route_is_removed` FAILS because the route still exists (returns 200). + +- [ ] **Step 3: Remove the route** + +In `src/quartermaster/routes.py`, delete the entire `update_entry_notes` handler (decorator + function). It spans roughly: + +```python +@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) +def update_entry_notes( + entry_id: int, + request: Request, + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + updated = service.set_entry_notes(db, entry_id, notes) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, updated.section) +``` + +- [ ] **Step 4: Run the migrated test to verify it now passes** + +``` +uv run pytest tests/test_notes.py::test_old_entry_notes_route_is_removed -v +``` + +Expected: PASS (route returns 404). + +- [ ] **Step 5: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +``` +git add src/quartermaster/routes.py tests/test_notes.py +git commit -m "refactor: remove POST /entries/{id}/notes, superseded by save route (#21)" +``` + +--- + +## Task 7: Remove `service.set_entry_notes` + +**Files:** +- Modify: `src/quartermaster/service.py` +- Modify: `tests/test_notes.py` + +- [ ] **Step 1: Remove the tests for `set_entry_notes`** + +In `tests/test_notes.py`, delete: + +```python +def test_set_entry_notes_updates(db): + entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00")) + updated = service.set_entry_notes(db, entry.id, "weekly Costco run") + assert updated is not None + assert updated.notes == "weekly Costco run" + + +def test_set_entry_notes_missing_returns_none(db): + assert service.set_entry_notes(db, 9999, "oops") is None +``` + +These are superseded by `test_update_entry_notes_set_and_clear` and `test_update_entry_missing_returns_none` in `test_service.py` (from Task 1). + +- [ ] **Step 2: Confirm no other caller remains** + +``` +uv run grep -rn "set_entry_notes" src tests +``` + +Expected: no output (empty). If there is any, stop and investigate; do not remove. + +- [ ] **Step 3: Delete the function** + +In `src/quartermaster/service.py`, remove: + +```python +def set_entry_notes( + db: Session, entry_id: int, notes: str | None +) -> Entry | None: + entry = db.get(Entry, entry_id) + if entry is None: + return None + entry.notes = _clean_notes(notes) + db.commit() + db.refresh(entry) + return entry +``` + +- [ ] **Step 4: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +``` +git add src/quartermaster/service.py tests/test_notes.py +git commit -m "refactor(service): remove set_entry_notes, superseded by update_entry (#21)" +``` + +--- + +## Task 8: End-to-end template-edit isolation test + +**Files:** +- Create: `tests/test_template_edit_isolation.py` + +- [ ] **Step 1: Write the failing test (which should already pass)** + +Create `tests/test_template_edit_isolation.py`: + +```python +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.models import Section + + +def test_template_edit_does_not_mutate_existing_month_and_applies_to_next(db): + # seed the template + twitch = service.add_entry( + db, Section.subscription, "Twitch", Decimal("10.99") + ) + + # create April 2026 (snapshots the current template) + april = month_service.create_month(db, "2026-04") + april_twitch = next( + e for e in april.entries if e.source_entry_id == twitch.id + ) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # edit the template + service.update_entry( + db, twitch.id, name="Twitch Prime", amount=Decimal("11.99") + ) + + # april is untouched + db.refresh(april_twitch) + assert april_twitch.planned == Decimal("10.99") + assert april_twitch.origin_planned == Decimal("10.99") + assert april_twitch.name == "Twitch" + assert april_twitch.origin_name == "Twitch" + + # creating May 2026 picks up the new values + may = month_service.create_month(db, "2026-05") + may_twitch = next(e for e in may.entries if e.source_entry_id == twitch.id) + assert may_twitch.planned == Decimal("11.99") + assert may_twitch.origin_planned == Decimal("11.99") + assert may_twitch.name == "Twitch Prime" + assert may_twitch.origin_name == "Twitch Prime" +``` + +- [ ] **Step 2: Run the test** + +``` +uv run pytest tests/test_template_edit_isolation.py -v +``` + +Expected: PASS (all behavior is already in place from Task 1). + +- [ ] **Step 3: Run the full suite** + +``` +uv run pytest -q +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +``` +git add tests/test_template_edit_isolation.py +git commit -m "test: end-to-end template-edit isolation across months (#21)" +``` + +--- + +## Task 9: Manual UI verification + +The feature looks right end-to-end in tests, but UI tests cannot catch visual regressions or HTMX wiring mistakes. Spin up the server and exercise the flow in a browser. + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +``` +uv run uvicorn quartermaster.main:app --reload +``` + +In a separate shell, confirm it answers: + +``` +curl -s http://127.0.0.1:8000/ | head -5 +``` + +- [ ] **Step 2: Manual checklist (open http://127.0.0.1:8000/ in a browser)** + + - [ ] Navigate to Flexible > Subscriptions. Add a temporary entry ("MockSub", "1.00"). + - [ ] Hover the row. Confirm: ✎ and ✕ icons both fade in; row background shifts to `--paper-stripe`. + - [ ] Click ✎. Confirm: row swaps to the edit layout. Name, amount, notes inputs present. Save and Cancel buttons present. No separate notes row below. + - [ ] Edit the name and amount. Click Save. Confirm: row returns to read mode with new values. The section total updates. The "Flexible" group total updates. The zero widget at the top updates. + - [ ] Click ✎ again; edit only the notes field to "hello". Click Save. Confirm: read row now shows the note as an italic badge after the name (`MockSub · hello`). + - [ ] Click ✎; click Cancel. Confirm: row returns to read mode, no values changed. + - [ ] Click ✎ on MockSub; click ✎ on a different subscription. Confirm: only the most recent row is in edit mode, the first reverts to read mode. + - [ ] Add a debt-minimum entry ("MockCard", "25.00"). Edit its amount to "30.00" via the ✎ flow. Confirm: the Primary Debt Target card (if set to this card) updates its displayed amount. + - [ ] Edit an existing subscription entry that is already snapshotted into a month. Open the month view. Confirm: the month's `planned` for that entry is unchanged; no "modified" badge appears. + - [ ] Create a new month *after* the template edit. Confirm: the new month's snapshot reflects the edited values. + - [ ] Delete the temporary MockSub and MockCard entries. + +- [ ] **Step 3: Stop the dev server** + +Ctrl+C the uvicorn process. + +- [ ] **Step 4 (if any visual fix was needed): Commit** + +If the manual pass revealed a CSS or template tweak, commit it: + +``` +git add ... +git commit -m "fix(ui): (#21)" +``` + +--- + +## Task 10: Open the pull request + +**Files:** none + +- [ ] **Step 1: Push the latest commits** + +``` +git push +``` + +- [ ] **Step 2: Open the PR via the gitea MCP** + +Use `mcp__gitea__pull_request_write method=create` with: +- owner: `archeious` +- repo: `quartermaster` +- base: `main` +- head: `feat/21-edit-template-entries` +- title: `Edit name/amount on budget template entries (#21)` +- body: link to the spec path and to issue #21, short summary of what changed (new save route, new edit/cancel routes, rewritten section template, removed notes-only route and service function, end-to-end isolation test). + +- [ ] **Step 3: Report PR number and URL back to the user.** + +Do not merge. The user merges manually after review (per repo workflow: "Merge via the Forgejo API/UI, not locally"). -- 2.45.2 From 6f98618b518ae68035464a0b4711f4a2828e3a30 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:45:12 -0600 Subject: [PATCH 03/12] feat(service): add update_entry for template rows (#21) --- src/quartermaster/service.py | 24 +++++++++++ tests/test_service.py | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index 5a9cc95..5eff831 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -15,6 +15,8 @@ from quartermaster.groups import ( ) from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section +_NOTES_SENTINEL = object() + def zero_tone(value: Decimal) -> str: if value == 0: @@ -125,6 +127,28 @@ def set_entry_notes( return entry +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 + + def delete_entry(db: Session, entry_id: int) -> Entry | None: entry = db.get(Entry, entry_id) if entry is None: diff --git a/tests/test_service.py b/tests/test_service.py index c03ede5..90b7a67 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -41,3 +41,83 @@ def test_debt_target_cleared_on_delete(db): service.delete_entry(db, dm.id) target = service.get_debt_target(db) assert target.debt_minimum_id is None + + +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" -- 2.45.2 From af276f0eec8ac5483173094f2c094f585daba6ee Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:50:46 -0600 Subject: [PATCH 04/12] feat(ui): rewrite budget section row for inline edit mode (#21) --- src/quartermaster/routes.py | 9 +- src/quartermaster/static/app.css | 297 +++++++++--------- .../templates/partials/section.html | 135 +++++--- tests/test_notes.py | 14 +- 4 files changed, 248 insertions(+), 207 deletions(-) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index b9283c4..041a03e 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -39,11 +39,16 @@ def _section_view(db: Session, section: Section) -> service.SectionView: def _render_section( - request: Request, db: Session, section: 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} + request, + "partials/section.html", + {"section": view, "editing_id": editing_id}, ) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 328bbfe..a1f2067 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -361,64 +361,160 @@ details.group[open] > summary .chevron::after { opacity: 0; } .total .applied { color: var(--ink-soft); } .total.empty { color: var(--muted); } -/* =============== ENTRY TABLE =============== */ +/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */ -table.entries { - width: 100%; - border-collapse: collapse; - font-family: var(--sans); -} -table.entries thead { display: none; } +.budget-entries { display: flex; flex-direction: column; } -table.entries tbody tr.entry { +.entry-row.reading { display: grid; - grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem; + grid-template-columns: minmax(0, 1fr) 5.5rem auto; gap: 0.6rem; align-items: baseline; padding: 0.26rem 0.25rem 0.28rem; - position: relative; border-bottom: 1px dotted var(--rule); + position: relative; } -table.entries tbody tr.entry:hover { background: var(--paper-stripe); } -table.entries tbody tr.entry td { - padding: 0; - border: none; - vertical-align: baseline; +.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; } -table.entries tbody tr.entry::after { - content: ""; - position: absolute; - left: 0; right: 0; bottom: -1px; - height: 2px; - background: var(--sage-soft); - width: min(100%, calc(var(--ratio, 1) * 100%)); - transition: width 0.25s ease; - opacity: 0.7; +.entry-row.reading .entry-actions { + display: flex; + gap: 0.3rem; + align-items: center; } -table.entries tbody tr.entry.state-edited::after, -table.entries tbody tr.entry[data-deviation="over"]::after { - background: var(--accent); - opacity: 0.85; +.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; } -table.entries tbody tr.entry.state-new_in_month::after { - background: var(--indigo); - opacity: 0.55; +.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); } + +.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); } -tr.empty td { - padding: 0.4rem 0.5rem !important; +.empty-row { + padding: 0.4rem 0.5rem; color: var(--muted); font-style: italic; font-size: 0.9rem; } -tr.add-row td { - padding: 0.4rem 0.25rem 0.2rem !important; - border-bottom: none !important; +.add-row { + padding: 0.4rem 0.25rem 0.2rem; grid-column: 1 / -1; - display: block; } /* Add-entry disclosure: collapsed trigger, expanded form */ @@ -455,112 +551,18 @@ details.add-entry > .month-add-form { margin-top: 0.45rem; } -.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; +@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; } } -.entry-name input { - font: inherit; - color: inherit; - background: transparent; - border: none; - border-bottom: 1px solid transparent; - padding: 0; - width: 100%; - outline: none; - transition: border-color 0.12s; -} -.entry-name input:hover { border-bottom-color: var(--rule); } -.entry-name input:focus { border-bottom-color: var(--ink); } - -.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-amount input { - font: inherit; - font-variant-numeric: tabular-nums; - color: inherit; - background: transparent; - border: none; - border-bottom: 1px solid transparent; - padding: 0; - width: 100%; - outline: none; - text-align: right; - transition: border-color 0.12s; -} -.entry-amount input:hover { border-bottom-color: var(--rule); } -.entry-amount input:focus { border-bottom-color: var(--ink); } - -/* On the budget page the planned-amount cell is the only numeric cell; - still render it like a ledger number. */ -tr.entry td.entry-amount:first-of-type { color: var(--ink); } - -.entry-actions button.delete { - font-family: var(--sans); - font-size: 1.2rem; - line-height: 1; - color: var(--rule); - background: none; - border: none; - cursor: pointer; - padding: 0; - opacity: 0; - transition: color 0.12s ease, opacity 0.12s ease; - align-self: center; - font-weight: 400; -} -tr.entry:hover .entry-actions button.delete { opacity: 1; } -.entry-actions button.delete:hover { color: var(--accent); } - -/* Notes row — hidden when empty, shown on hover or when value is set */ -tr.entry-notes-row { - display: block; - grid-column: 1 / -1; - font-family: var(--sans); - font-style: italic; - font-weight: 400; - font-size: 0.85rem; - color: var(--muted); - line-height: 1.3; - padding: 0; - margin-top: -0.25rem; -} -tr.entry-notes-row td { - padding: 0 0.25rem 0.25rem !important; - border-bottom: 1px dotted var(--rule) !important; -} -tr.entry-notes-row input.notes-input { - font: inherit; - font-style: italic; - color: inherit; - background: transparent; - border: none; - padding: 0; - width: 100%; - outline: none; -} -tr.entry-notes-row input.notes-input::placeholder { - color: var(--rule); - font-style: italic; -} -/* Empty notes render subtly (placeholder only) so they stay clickable. */ -tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; } -tr.entry-notes-row:hover, -tr.entry-notes-row:has(input:focus) { opacity: 1; } .tag { font-family: var(--sans); @@ -980,13 +982,6 @@ form.add-posting-form button[type="submit"] { } form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); } -.empty-row { - padding: 0.5rem 0.5rem; - color: var(--muted); - font-style: italic; - font-size: 0.9rem; -} - @media (max-width: 640px) { .entry-block > summary { grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem; @@ -1118,8 +1113,4 @@ button[disabled] { text-align: center; justify-self: center; } - table.entries tbody tr.entry { - grid-template-columns: minmax(0, 1fr) 4.6rem 4.6rem 1rem; - gap: 0.4rem; - } } diff --git a/src/quartermaster/templates/partials/section.html b/src/quartermaster/templates/partials/section.html index 1b45f7d..116db33 100644 --- a/src/quartermaster/templates/partials/section.html +++ b/src/quartermaster/templates/partials/section.html @@ -5,14 +5,67 @@ ${{ '{:,.2f}'.format(section.total) }} - - - {% for entry in section.entries %} - - - - - - - - - - {% else %} - - {% endfor %} - - - - -
{{ entry.name }}${{ '{:,.2f}'.format(entry.amount) }} +
+ {% 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) }} +
+ -
- -
No entries yet.
-
- + add {{ section.label|lower }} -
- - - - -
-
-
+ + + {% endif %} + {% else %} +
No entries yet.
+ {% endfor %} +
+
+ + add {{ section.label|lower }} +
+ + + + +
+
+
+ diff --git a/tests/test_notes.py b/tests/test_notes.py index e652528..542492f 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -136,17 +136,27 @@ def test_update_month_entry_route_accepts_notes(client): assert "auto-pay" in response.text -def test_budget_page_renders_notes_inputs(client): +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 "entry-notes-row" in response.text + 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 + + def test_month_page_renders_notes_inputs(client): client.post( "/sections/fixed_bill/entries", -- 2.45.2 From c96d3f5d2fa3a7db9a51b6a94bee9632d9fd48b3 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 18:59:04 -0600 Subject: [PATCH 05/12] fix(ui): restore target card layout, tighten edit-mode UX (#21) --- src/quartermaster/routes.py | 1 + src/quartermaster/static/app.css | 25 +++++++++++++++++++ .../templates/partials/section.html | 1 + 3 files changed, 27 insertions(+) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 041a03e..54da710 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -102,6 +102,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: "all_months": month_service.list_months(db), "zero": zero, "tone": service.zero_tone(zero), + "editing_id": None, }, ) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index a1f2067..e00bc7b 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -580,6 +580,31 @@ details.add-entry > .month-add-form { .tag-edited { color: var(--ochre); } .tag-new { color: var(--indigo); } +/* Target card keeps the table-based layout from before Task 2 rewrite. */ +.target-section table.entries { width: 100%; border-collapse: collapse; font-family: var(--sans); } +.target-section table.entries thead { display: none; } +.target-section table.entries tbody tr.entry { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem; + gap: 0.6rem; + align-items: baseline; + padding: 0.26rem 0.25rem 0.28rem; + position: relative; + border-bottom: 1px dotted var(--rule); +} +.target-section table.entries tbody tr.entry td { + padding: 0; + border: none; + vertical-align: baseline; + min-width: 0; +} +.target-section table.entries tr.add-row td { + padding: 0.4rem 0.25rem 0.2rem !important; + border-bottom: none !important; + grid-column: 1 / -1; + display: block; +} + /* =============== PRIMARY DEBT TARGET =============== */ .section.target-section { diff --git a/src/quartermaster/templates/partials/section.html b/src/quartermaster/templates/partials/section.html index 116db33..deab8d3 100644 --- a/src/quartermaster/templates/partials/section.html +++ b/src/quartermaster/templates/partials/section.html @@ -20,6 +20,7 @@ name="name" value="{{ entry.name }}" required + autofocus aria-label="Name" > Date: Fri, 17 Apr 2026 19:01:52 -0600 Subject: [PATCH 06/12] feat(routes): add GET /entries/{id}/edit for edit-mode toggle (#21) --- src/quartermaster/routes.py | 14 +++++++++++++- tests/test_routes.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 54da710..eece90a 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from quartermaster import month_service, service from quartermaster.db import get_session -from quartermaster.models import SECTION_LABELS, Section +from quartermaster.models import SECTION_LABELS, Entry, Section TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -150,6 +150,18 @@ def remove_entry( return _append_oob(response, *extras) +@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) + + @router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) def update_entry_notes( entry_id: int, diff --git a/tests/test_routes.py b/tests/test_routes.py index 60ba013..72767e8 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -86,3 +86,31 @@ def test_reject_non_debt_minimum_target(client): ) response = client.post("/debt-target", data={"debt_minimum_id": "1"}) assert response.status_code == 400 + + +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 + assert response.text.count('entry-row editing') == 1 + assert response.text.count('entry-row reading') == 1 -- 2.45.2 From c331211afd735cba58e2c73c9229978cd76dc70d Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:05:09 -0600 Subject: [PATCH 07/12] feat(routes): POST /entries/{id} saves edits with OOB totals (#21) --- src/quartermaster/routes.py | 28 ++++++++++++ tests/test_routes.py | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index eece90a..8642e5c 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -162,6 +162,34 @@ def edit_entry( return _render_section(request, db, entry.section, editing_id=entry.id) +@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) + + @router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) def update_entry_notes( entry_id: int, diff --git a/tests/test_routes.py b/tests/test_routes.py index 72767e8..ff301d2 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -114,3 +114,93 @@ def test_get_entry_edit_other_rows_stay_in_read_mode(client): assert response.status_code == 200 assert response.text.count('entry-row editing') == 1 assert response.text.count('entry-row reading') == 1 + + +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 -- 2.45.2 From a814ec6e01c3e5026311240a706cbe68e50ceadf Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:08:38 -0600 Subject: [PATCH 08/12] feat(routes): GET /sections/{section} for edit-mode cancel (#21) --- src/quartermaster/routes.py | 9 +++++++++ tests/test_routes.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 8642e5c..6043e9e 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -190,6 +190,15 @@ def save_entry( return _append_oob(response, *extras) +@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) + + @router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) def update_entry_notes( entry_id: int, diff --git a/tests/test_routes.py b/tests/test_routes.py index ff301d2..2fcd9db 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -204,3 +204,24 @@ def test_post_entry_missing_returns_404(client): data={"name": "Whatever", "amount": "1.00", "notes": ""}, ) assert response.status_code == 404 + + +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 -- 2.45.2 From 1c525f0202caf6c8b2e7df958cd6b12a48865bc2 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:11:01 -0600 Subject: [PATCH 09/12] refactor: remove POST /entries/{id}/notes, superseded by save route (#21) --- src/quartermaster/routes.py | 13 ----------- tests/test_notes.py | 43 ++++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 6043e9e..c7c91a7 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -199,19 +199,6 @@ def get_section( return _render_section(request, db, section) -@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) - - @router.post("/debt-target", response_class=HTMLResponse) def update_debt_target( request: Request, diff --git a/tests/test_notes.py b/tests/test_notes.py index 542492f..72731f4 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -91,26 +91,39 @@ def test_create_entry_route_accepts_notes(client): assert "3 mo cushion" in response.text -def test_update_entry_notes_route(client): +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 == 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 + assert response.status_code == 404 def test_create_month_entry_route_accepts_notes(client): -- 2.45.2 From fe419fe8025e4c6de3f18d5fbd7ac3e6db221b29 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:12:43 -0600 Subject: [PATCH 10/12] refactor(service): remove set_entry_notes, superseded by update_entry (#21) --- src/quartermaster/service.py | 11 ----------- tests/test_notes.py | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index 5eff831..7fc0d92 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -115,17 +115,6 @@ def _clean_notes(raw: str | None) -> str | None: return stripped if stripped else None -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 - def update_entry( db: Session, diff --git a/tests/test_notes.py b/tests/test_notes.py index 72731f4..bfcbe7c 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -24,16 +24,6 @@ def test_add_entry_strips_and_nulls_empty_notes(db): assert entry.notes is None -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 - def test_snapshot_copies_notes(db): service.add_entry( -- 2.45.2 From c2afacfe6e19f0b9da34206ed051a04090382b18 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:14:14 -0600 Subject: [PATCH 11/12] test: end-to-end template-edit isolation across months (#21) --- tests/test_template_edit_isolation.py | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_template_edit_isolation.py diff --git a/tests/test_template_edit_isolation.py b/tests/test_template_edit_isolation.py new file mode 100644 index 0000000..3cdb224 --- /dev/null +++ b/tests/test_template_edit_isolation.py @@ -0,0 +1,43 @@ +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" -- 2.45.2 From 73825bc30511bb631e4e5adec8d17aec5b36c4a6 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 19:19:51 -0600 Subject: [PATCH 12/12] chore: drop dead --ratio attribute, tidy service.py whitespace (#21) --- src/quartermaster/service.py | 1 - src/quartermaster/templates/partials/target_card.html | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index 7fc0d92..674e2d2 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -115,7 +115,6 @@ def _clean_notes(raw: str | None) -> str | None: return stripped if stripped else None - def update_entry( db: Session, entry_id: int, diff --git a/src/quartermaster/templates/partials/target_card.html b/src/quartermaster/templates/partials/target_card.html index 6f70691..44efb9a 100644 --- a/src/quartermaster/templates/partials/target_card.html +++ b/src/quartermaster/templates/partials/target_card.html @@ -9,7 +9,7 @@ - + -- 2.45.2
{% if target.entry %}{{ target.entry.name }}{% else %}No target selected.{% endif %}