From 4d40843e241a2c1bf7b81f5178872ed76c685019 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:51:22 -0600 Subject: [PATCH 1/4] feat(db): add nullable notes column to entry and month_entry Free-text annotation up to 1024 chars. Nullable so existing rows need no backfill; an empty or whitespace-only input will be normalised to NULL in the service layer. Alembic batch_alter_table handles the SQLite table rebuild automatically. Refs #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6d_add_notes_column_to_entry_and_month_.py | 42 +++++++++++++++++++ src/quartermaster/models.py | 2 + 2 files changed, 44 insertions(+) create mode 100644 alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py diff --git a/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py b/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py new file mode 100644 index 0000000..8dac030 --- /dev/null +++ b/alembic/versions/ec804bdf366d_add_notes_column_to_entry_and_month_.py @@ -0,0 +1,42 @@ +"""add notes column to entry and month_entry + +Revision ID: ec804bdf366d +Revises: 03ebe3c07262 +Create Date: 2026-04-17 12:47:41.533978 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ec804bdf366d' +down_revision: Union[str, Sequence[str], None] = '03ebe3c07262' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('entry', schema=None) as batch_op: + batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True)) + + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.drop_column('notes') + + with op.batch_alter_table('entry', schema=None) as batch_op: + batch_op.drop_column('notes') + + # ### end Alembic commands ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index ee81008..c2edacb 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -51,6 +51,7 @@ class Entry(Base): ) name: Mapped[str] = mapped_column(String(128), nullable=False) amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + notes: Mapped[str | None] = mapped_column(String(1024), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) @@ -117,6 +118,7 @@ class MonthEntry(Base): Numeric(10, 2), nullable=False, default=Decimal("0.00"), server_default="0.00", ) + notes: Mapped[str | None] = mapped_column(String(1024), nullable=True) origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True) origin_planned: Mapped[Decimal | None] = mapped_column( Numeric(10, 2), nullable=True -- 2.45.2 From 034a8d65f58e731ac922d466bf72236203e0f40e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:51:25 -0600 Subject: [PATCH 2/4] feat(notes): service-layer support for notes on entry and month_entry add_entry and add_month_entry accept an optional notes keyword. A new set_entry_notes function updates a single budget entry's notes. update_month_entry gains a notes parameter guarded by a sentinel so callers can distinguish "do not touch notes" from "clear to NULL". create_month copies entry.notes into each freshly snapshotted month_entry. Blank / whitespace notes normalise to NULL. Refs #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/month_service.py | 16 ++++++++++++++ src/quartermaster/service.py | 34 ++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 92e7b9f..8928aad 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -112,6 +112,7 @@ def create_month(db: Session, year_month: str) -> Month: name=e.name, planned=e.amount, applied=Decimal("0.00"), + notes=e.notes, origin_name=e.name, origin_planned=e.amount, source_entry_id=e.id, @@ -203,12 +204,20 @@ def section_view(month: Month, section: Section, label: str) -> MonthSectionView ) +def _clean_notes(raw: str | None) -> str | None: + if raw is None: + return None + stripped = raw.strip() + return stripped if stripped else None + + def add_month_entry( db: Session, month: Month, section: Section, name: str, planned: Decimal, + notes: str | None = None, ) -> MonthEntry: entry = MonthEntry( month_id=month.id, @@ -216,6 +225,7 @@ def add_month_entry( name=name.strip(), planned=planned, applied=Decimal("0.00"), + notes=_clean_notes(notes), origin_name=None, origin_planned=None, source_entry_id=None, @@ -243,6 +253,9 @@ def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | No return section +_NOTES_SENTINEL = object() + + def update_month_entry( db: Session, month: Month, @@ -251,6 +264,7 @@ def update_month_entry( name: str | None = None, planned: Decimal | None = None, applied: Decimal | None = None, + notes: str | None | object = _NOTES_SENTINEL, ) -> MonthEntry | None: entry = get_month_entry(db, month, entry_id) if entry is None: @@ -261,6 +275,8 @@ def update_month_entry( entry.planned = planned if applied is not None: entry.applied = applied + if notes is not _NOTES_SENTINEL: + entry.notes = _clean_notes(notes) # type: ignore[arg-type] db.commit() db.refresh(entry) return entry diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index 7ed9fb7..5a9cc95 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -87,14 +87,44 @@ def budget_zero(db: Session) -> Decimal: return (total_income - total_non_income).quantize(Decimal("0.01")) -def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry: - entry = Entry(section=section, name=name.strip(), amount=amount) +def add_entry( + db: Session, + section: Section, + name: str, + amount: Decimal, + notes: str | None = None, +) -> Entry: + entry = Entry( + section=section, + name=name.strip(), + amount=amount, + notes=_clean_notes(notes), + ) db.add(entry) db.commit() db.refresh(entry) return entry +def _clean_notes(raw: str | None) -> str | None: + if raw is None: + return None + stripped = raw.strip() + return stripped if stripped else None + + +def set_entry_notes( + db: Session, entry_id: int, notes: str | None +) -> Entry | None: + entry = db.get(Entry, entry_id) + if entry is None: + return None + entry.notes = _clean_notes(notes) + db.commit() + db.refresh(entry) + return entry + + def delete_entry(db: Session, entry_id: int) -> Entry | None: entry = db.get(Entry, entry_id) if entry is None: -- 2.45.2 From 0f2f549d855e659d6fd820d35756be81881b18b3 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:51:31 -0600 Subject: [PATCH 3/4] feat(notes): render and wire notes inputs on budget and month pages Each entry row gains a secondary notes row with an inline-editable text input. Budget entries post to a new /entries/{id}/notes endpoint; month entries reuse the existing update route. Add forms gain an optional "notes (optional)" input that spans the form row. Notes render muted with a dashed underline on hover to signal editability without cluttering the layout. Changing notes on a month row does not flip the deviation state since the financial values are unchanged. Refs #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/routes.py | 16 +++++++++- src/quartermaster/routes_month.py | 12 ++++--- src/quartermaster/static/app.css | 31 +++++++++++++++++++ .../templates/partials/month_section.html | 17 ++++++++++ .../templates/partials/section.html | 17 ++++++++++ 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index 926eb60..b9283c4 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -107,13 +107,14 @@ def create_entry( request: Request, name: str = Form(...), amount: str = Form(...), + notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: clean_name = name.strip() if not clean_name: raise HTTPException(status_code=400, detail="name is required") parsed = _parse_amount(amount) - service.add_entry(db, section, clean_name, parsed) + service.add_entry(db, section, clean_name, parsed, notes=notes) response = _render_section(request, db, section) extras: list[HTMLResponse] = [ _render_zero(request, db), @@ -143,6 +144,19 @@ def remove_entry( return _append_oob(response, *extras) +@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse) +def update_entry_notes( + entry_id: int, + request: Request, + notes: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + updated = service.set_entry_notes(db, entry_id, notes) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + return _render_section(request, db, updated.section) + + @router.post("/debt-target", response_class=HTMLResponse) def update_debt_target( request: Request, diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index 8faa748..b6ee5e2 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -164,6 +164,7 @@ def add_month_entry( request: Request, name: str = Form(...), planned: str = Form(...), + notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: month = _require_month(db, year_month) @@ -171,7 +172,7 @@ def add_month_entry( if not clean_name: raise HTTPException(status_code=400, detail="name is required") month_service.add_month_entry( - db, month, section, clean_name, _parse_amount(planned) + db, month, section, clean_name, _parse_amount(planned), notes=notes ) db.refresh(month) return _append_oob( @@ -210,6 +211,7 @@ def update_month_entry( name: str | None = Form(None), planned: str | None = Form(None), applied: str | None = Form(None), + notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: month = _require_month(db, year_month) @@ -220,14 +222,14 @@ def update_month_entry( raise HTTPException(status_code=400, detail="name must not be empty") parsed_planned = _parse_amount(planned) if planned is not None else None parsed_applied = _parse_amount(applied) if applied is not None else None - updated = month_service.update_month_entry( - db, - month, - entry_id, + kwargs: dict = dict( name=clean_name, planned=parsed_planned, applied=parsed_applied, ) + if notes is not None: + kwargs["notes"] = notes + updated = month_service.update_month_entry(db, month, entry_id, **kwargs) if updated is None: raise HTTPException(status_code=404, detail="entry not found") db.refresh(month) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 8a9fb97..5106d1e 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -111,6 +111,37 @@ tr.add-row td { .muted { color: var(--muted); font-style: italic; } +tr.entry-notes-row td { + padding: 0 0.5rem 0.35rem; + border-bottom: 1px solid var(--rule); +} + +.notes-input { + width: 100%; + padding: 0.2rem 0.4rem; + font-size: 0.85rem; + color: var(--muted); + background: transparent; + border: none; + border-bottom: 1px dashed transparent; +} + +.notes-input:hover, +.notes-input:focus { + border-bottom-color: var(--rule); + outline: none; +} + +.notes-input:not(:placeholder-shown) { + color: var(--ink); + font-style: italic; +} + +.add-form .add-notes { + grid-column: 1 / -1; + font-size: 0.85rem; +} + button.delete { background: transparent; border: none; diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html index 922316d..88b6d6f 100644 --- a/src/quartermaster/templates/partials/month_section.html +++ b/src/quartermaster/templates/partials/month_section.html @@ -68,6 +68,22 @@ >× + + + + + {% else %} No entries. {% endfor %} @@ -83,6 +99,7 @@ + diff --git a/src/quartermaster/templates/partials/section.html b/src/quartermaster/templates/partials/section.html index 59210c8..8abe715 100644 --- a/src/quartermaster/templates/partials/section.html +++ b/src/quartermaster/templates/partials/section.html @@ -22,6 +22,22 @@ >× + + + + + {% else %} No entries yet. {% endfor %} @@ -37,6 +53,7 @@ + -- 2.45.2 From 28d097cfbf09ed043b0fde5cedaaae6308627a68 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:51:36 -0600 Subject: [PATCH 4/4] test: cover notes add, update, clear, snapshot copy, and no-deviation Service tests hit create, update, missing-id, blank-collapses-to-null, and the snapshot copying behaviour. Route tests hit both pages' create flows, the budget notes endpoint, and the month update route. A dedicated assertion confirms that changing notes on a month entry does not flip the deviation state: notes are free-form annotation, not a signal of plan drift. Refs #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_notes.py | 159 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_notes.py diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..498a526 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.models import Section + + +def test_add_entry_stores_notes(db): + entry = service.add_entry( + db, + Section.sinking_fund, + "Emergency", + Decimal("500.00"), + notes="Target: 3 months of expenses", + ) + assert entry.notes == "Target: 3 months of expenses" + + +def test_add_entry_strips_and_nulls_empty_notes(db): + entry = service.add_entry( + db, Section.other, "Gift", Decimal("25.00"), notes=" " + ) + 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( + db, + Section.sinking_fund, + "Emergency", + Decimal("500.00"), + notes="3 months expenses", + ) + month = month_service.create_month(db, "2026-04") + emergency = next(e for e in month.entries if e.origin_name == "Emergency") + assert emergency.notes == "3 months expenses" + + +def test_month_notes_edit_does_not_change_deviation_state(db): + service.add_entry( + db, Section.fixed_bill, "Rent", Decimal("1200.00"), notes="auto-pay" + ) + month = month_service.create_month(db, "2026-04") + rent = next(e for e in month.entries if e.origin_name == "Rent") + # unchanged initially + assert ( + month_service.deviation_state(rent) == month_service.DeviationState.unchanged + ) + # update notes only + month_service.update_month_entry( + db, month, rent.id, notes="auto-pay; renew July 2027" + ) + db.refresh(rent) + assert rent.notes == "auto-pay; renew July 2027" + assert ( + month_service.deviation_state(rent) == month_service.DeviationState.unchanged + ) + + +def test_update_month_entry_notes_to_empty_nulls(db): + service.add_entry(db, Section.other, "Parking", Decimal("25.00"), notes="work") + month = month_service.create_month(db, "2026-04") + entry = next(e for e in month.entries if e.origin_name == "Parking") + assert entry.notes == "work" + month_service.update_month_entry(db, month, entry.id, notes="") + db.refresh(entry) + assert entry.notes is None + + +# --- route-level ----------------------------------------------------------- + + +def test_create_entry_route_accepts_notes(client): + response = client.post( + "/sections/sinking_fund/entries", + data={"name": "Emergency", "amount": "500.00", "notes": "3 mo cushion"}, + ) + assert response.status_code == 200 + assert "3 mo cushion" in response.text + + +def test_update_entry_notes_route(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + response = client.post("/entries/1/notes", data={"notes": "weekly"}) + assert response.status_code == 200 + assert "weekly" in response.text + + +def test_update_entry_notes_empty_clears(client): + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00", "notes": "weekly"}, + ) + response = client.post("/entries/1/notes", data={"notes": ""}) + assert response.status_code == 200 + # the input's value="" still renders but the placeholder kicks in; + # specifically, no literal "weekly" anymore + assert "value=\"weekly\"" not in response.text + + +def test_create_month_entry_route_accepts_notes(client): + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/sections/other/entries", + data={"name": "Gift", "planned": "25.00", "notes": "birthday"}, + ) + assert response.status_code == 200 + assert "birthday" in response.text + + +def test_update_month_entry_route_accepts_notes(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00"}, + ) + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/entries/1", data={"notes": "auto-pay"} + ) + assert response.status_code == 200 + assert "auto-pay" in response.text + + +def test_budget_page_renders_notes_inputs(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"}, + ) + response = client.get("/") + assert response.status_code == 200 + assert "entry-notes-row" in response.text + assert "due 1st" in response.text + + +def test_month_page_renders_notes_inputs(client): + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00", "notes": "auto-pay"}, + ) + client.post("/month/2026-04/create") + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "entry-notes-row" in response.text + assert "auto-pay" in response.text -- 2.45.2