Edit name/amount on budget template entries (#21) #22

Merged
claude-code merged 12 commits from feat/21-edit-template-entries into main 2026-04-17 19:28:56 -06:00
12 changed files with 2171 additions and 241 deletions

3
.gitignore vendored
View file

@ -172,3 +172,6 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Superpowers brainstorm companion working dir
.superpowers/

File diff suppressed because it is too large Load diff

View file

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

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from quartermaster import month_service, service from quartermaster import month_service, service
from quartermaster.db import get_session 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_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@ -39,11 +39,16 @@ def _section_view(db: Session, section: Section) -> service.SectionView:
def _render_section( def _render_section(
request: Request, db: Session, section: Section request: Request,
db: Session,
section: Section,
editing_id: int | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
view = _section_view(db, section) view = _section_view(db, section)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/section.html", {"section": view} request,
"partials/section.html",
{"section": view, "editing_id": editing_id},
) )
@ -97,6 +102,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
"all_months": month_service.list_months(db), "all_months": month_service.list_months(db),
"zero": zero, "zero": zero,
"tone": service.zero_tone(zero), "tone": service.zero_tone(zero),
"editing_id": None,
}, },
) )
@ -144,17 +150,53 @@ def remove_entry(
return _append_oob(response, *extras) return _append_oob(response, *extras)
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) @router.get("/entries/{entry_id}/edit", response_class=HTMLResponse)
def update_entry_notes( def edit_entry(
entry_id: int, entry_id: int,
request: Request, 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}", response_class=HTMLResponse)
def save_entry(
entry_id: int,
request: Request,
name: str = Form(...),
amount: str = Form(...),
notes: str | None = Form(None), notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
updated = service.set_entry_notes(db, entry_id, notes) 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: if updated is None:
raise HTTPException(status_code=404, detail="entry not found") raise HTTPException(status_code=404, detail="entry not found")
return _render_section(request, db, updated.section) 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.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("/debt-target", response_class=HTMLResponse) @router.post("/debt-target", response_class=HTMLResponse)

View file

@ -15,6 +15,8 @@ from quartermaster.groups import (
) )
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
_NOTES_SENTINEL = object()
def zero_tone(value: Decimal) -> str: def zero_tone(value: Decimal) -> str:
if value == 0: if value == 0:
@ -113,13 +115,23 @@ def _clean_notes(raw: str | None) -> str | None:
return stripped if stripped else None return stripped if stripped else None
def set_entry_notes( def update_entry(
db: Session, entry_id: int, notes: str | None db: Session,
entry_id: int,
*,
name: str | None = None,
amount: Decimal | None = None,
notes: str | None | object = _NOTES_SENTINEL,
) -> Entry | None: ) -> Entry | None:
entry = db.get(Entry, entry_id) entry = db.get(Entry, entry_id)
if entry is None: if entry is None:
return None return None
entry.notes = _clean_notes(notes) 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.commit()
db.refresh(entry) db.refresh(entry)
return entry return entry

View file

@ -361,64 +361,160 @@ details.group[open] > summary .chevron::after { opacity: 0; }
.total .applied { color: var(--ink-soft); } .total .applied { color: var(--ink-soft); }
.total.empty { color: var(--muted); } .total.empty { color: var(--muted); }
/* =============== ENTRY TABLE =============== */ /* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */
table.entries { .budget-entries { display: flex; flex-direction: column; }
width: 100%;
border-collapse: collapse;
font-family: var(--sans);
}
table.entries thead { display: none; }
table.entries tbody tr.entry { .entry-row.reading {
display: grid; 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; gap: 0.6rem;
align-items: baseline; align-items: baseline;
padding: 0.26rem 0.25rem 0.28rem; padding: 0.26rem 0.25rem 0.28rem;
position: relative;
border-bottom: 1px dotted var(--rule); border-bottom: 1px dotted var(--rule);
position: relative;
} }
table.entries tbody tr.entry:hover { background: var(--paper-stripe); } .entry-row.reading:hover { background: var(--paper-stripe); }
table.entries tbody tr.entry td {
padding: 0; .entry-row.reading .entry-name {
border: none; font-family: var(--sans);
vertical-align: baseline; 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; min-width: 0;
} }
table.entries tbody tr.entry::after { .entry-row.reading .entry-actions {
content: ""; display: flex;
position: absolute; gap: 0.3rem;
left: 0; right: 0; bottom: -1px; align-items: center;
height: 2px;
background: var(--sage-soft);
width: min(100%, calc(var(--ratio, 1) * 100%));
transition: width 0.25s ease;
opacity: 0.7;
} }
table.entries tbody tr.entry.state-edited::after, .entry-row.reading .entry-actions button {
table.entries tbody tr.entry[data-deviation="over"]::after { background: none;
background: var(--accent); border: none;
opacity: 0.85; 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 { .entry-row.reading:hover .entry-actions button { opacity: 1; }
background: var(--indigo); .entry-row.reading .entry-actions button.edit {
opacity: 0.55; 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 { .empty-row {
padding: 0.4rem 0.5rem !important; padding: 0.4rem 0.5rem;
color: var(--muted); color: var(--muted);
font-style: italic; font-style: italic;
font-size: 0.9rem; font-size: 0.9rem;
} }
tr.add-row td { .add-row {
padding: 0.4rem 0.25rem 0.2rem !important; padding: 0.4rem 0.25rem 0.2rem;
border-bottom: none !important;
grid-column: 1 / -1; grid-column: 1 / -1;
display: block;
} }
/* Add-entry disclosure: collapsed trigger, expanded form */ /* Add-entry disclosure: collapsed trigger, expanded form */
@ -455,112 +551,18 @@ details.add-entry > .month-add-form {
margin-top: 0.45rem; margin-top: 0.45rem;
} }
.entry-name { @media (max-width: 520px) {
font-family: var(--sans); .entry-row.reading {
font-weight: 500; grid-template-columns: minmax(0, 1fr) 4.6rem auto;
font-size: 1.02rem; gap: 0.4rem;
color: var(--ink); }
letter-spacing: 0.01em; .entry-row.editing {
min-width: 0; grid-template-columns: minmax(0, 1fr) 4.6rem;
overflow: hidden; gap: 0.4rem;
text-overflow: ellipsis; }
white-space: nowrap; .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 { .tag {
font-family: var(--sans); font-family: var(--sans);
@ -578,6 +580,31 @@ tr.entry-notes-row:has(input:focus) { opacity: 1; }
.tag-edited { color: var(--ochre); } .tag-edited { color: var(--ochre); }
.tag-new { color: var(--indigo); } .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 =============== */ /* =============== PRIMARY DEBT TARGET =============== */
.section.target-section { .section.target-section {
@ -980,13 +1007,6 @@ form.add-posting-form button[type="submit"] {
} }
form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); } 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) { @media (max-width: 640px) {
.entry-block > summary { .entry-block > summary {
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem; grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
@ -1118,8 +1138,4 @@ button[disabled] {
text-align: center; text-align: center;
justify-self: center; justify-self: center;
} }
table.entries tbody tr.entry {
grid-template-columns: minmax(0, 1fr) 4.6rem 4.6rem 1rem;
gap: 0.4rem;
}
} }

View file

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

View file

@ -9,7 +9,7 @@
</div> </div>
<table class="entries"> <table class="entries">
<tbody> <tbody>
<tr class="entry" style="--ratio: 1"> <tr class="entry">
<td class="entry-name"> <td class="entry-name">
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %} {% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
</td> </td>

View file

@ -24,16 +24,6 @@ def test_add_entry_strips_and_nulls_empty_notes(db):
assert entry.notes is None 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): def test_snapshot_copies_notes(db):
service.add_entry( service.add_entry(
@ -91,26 +81,39 @@ def test_create_entry_route_accepts_notes(client):
assert "3 mo cushion" in response.text 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( client.post(
"/sections/food/entries", "/sections/food/entries",
data={"name": "Groceries", "amount": "400.00"}, data={"name": "Groceries", "amount": "400.00"},
) )
response = client.post("/entries/1/notes", data={"notes": "weekly"}) response = client.post("/entries/1/notes", data={"notes": "weekly"})
assert response.status_code == 200 assert response.status_code == 404
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
def test_create_month_entry_route_accepts_notes(client): def test_create_month_entry_route_accepts_notes(client):
@ -136,17 +139,27 @@ def test_update_month_entry_route_accepts_notes(client):
assert "auto-pay" in response.text 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( client.post(
"/sections/fixed_bill/entries", "/sections/fixed_bill/entries",
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
) )
response = client.get("/") response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200
assert "entry-notes-row" in response.text assert "note-badge" in response.text
assert "due 1st" 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): def test_month_page_renders_notes_inputs(client):
client.post( client.post(
"/sections/fixed_bill/entries", "/sections/fixed_bill/entries",

View file

@ -86,3 +86,142 @@ def test_reject_non_debt_minimum_target(client):
) )
response = client.post("/debt-target", data={"debt_minimum_id": "1"}) response = client.post("/debt-target", data={"debt_minimum_id": "1"})
assert response.status_code == 400 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
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
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

View file

@ -41,3 +41,83 @@ def test_debt_target_cleared_on_delete(db):
service.delete_entry(db, dm.id) service.delete_entry(db, dm.id)
target = service.get_debt_target(db) target = service.get_debt_target(db)
assert target.debt_minimum_id is None 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"

View file

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