Edit name/amount on budget template entries (#21) #22
12 changed files with 2171 additions and 241 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
1256
docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md
Normal file
1256
docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
@ -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))
|
||||
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -97,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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -144,17 +150,53 @@ def remove_entry(
|
|||
return _append_oob(response, *extras)
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse)
|
||||
def update_entry_notes(
|
||||
@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}", 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:
|
||||
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:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -113,13 +115,23 @@ 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
|
||||
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
|
||||
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.refresh(entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -578,6 +580,31 @@ tr.entry-notes-row:has(input:focus) { opacity: 1; }
|
|||
.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 {
|
||||
|
|
@ -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); }
|
||||
|
||||
.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 +1138,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,68 @@
|
|||
${{ '{:,.2f}'.format(section.total) }}
|
||||
</span>
|
||||
</div>
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
{% for entry in section.entries %}
|
||||
<tr class="entry state-unchanged" style="--ratio: 1">
|
||||
<td class="entry-name">{{ entry.name }}</td>
|
||||
<td class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</td>
|
||||
<td class="entry-amount"></td>
|
||||
<td class="entry-actions">
|
||||
<div class="entries budget-entries">
|
||||
{% for entry in section.entries %}
|
||||
{% if editing_id is not none and entry.id == editing_id %}
|
||||
<form
|
||||
class="entry-row editing"
|
||||
hx-post="/entries/{{ entry.id }}"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
name="name"
|
||||
value="{{ entry.name }}"
|
||||
required
|
||||
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 }}"
|
||||
>✎</button>
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
|
|
@ -21,46 +75,28 @@
|
|||
hx-swap="outerHTML"
|
||||
aria-label="Delete {{ entry.name }}"
|
||||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
<td colspan="4">
|
||||
<input
|
||||
class="notes-input"
|
||||
type="text"
|
||||
name="notes"
|
||||
value="{{ entry.notes or '' }}"
|
||||
placeholder="notes..."
|
||||
hx-post="/entries/{{ entry.id }}/notes"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Notes for {{ entry.name }}"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty"><td colspan="4">No entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<details class="add-entry">
|
||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||
<form
|
||||
class="add-form"
|
||||
hx-post="/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||
>
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-row">No entries yet.</div>
|
||||
{% endfor %}
|
||||
<div class="add-row">
|
||||
<details class="add-entry">
|
||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||
<form
|
||||
class="add-form"
|
||||
hx-post="/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||
>
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</div>
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
<tr class="entry" style="--ratio: 1">
|
||||
<tr class="entry">
|
||||
<td class="entry-name">
|
||||
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -91,26 +81,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):
|
||||
|
|
@ -136,17 +139,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",
|
||||
|
|
|
|||
|
|
@ -86,3 +86,142 @@ 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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
43
tests/test_template_edit_isolation.py
Normal file
43
tests/test_template_edit_isolation.py
Normal 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"
|
||||
Loading…
Reference in a new issue