Notes field per entry (#14)

This commit is contained in:
claude-code 2026-04-17 12:54:24 -06:00
commit d040b7b66c
10 changed files with 338 additions and 8 deletions

View file

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

View file

@ -51,6 +51,7 @@ class Entry(Base):
) )
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False 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"), Numeric(10, 2), nullable=False, default=Decimal("0.00"),
server_default="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_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
origin_planned: Mapped[Decimal | None] = mapped_column( origin_planned: Mapped[Decimal | None] = mapped_column(
Numeric(10, 2), nullable=True Numeric(10, 2), nullable=True

View file

@ -112,6 +112,7 @@ def create_month(db: Session, year_month: str) -> Month:
name=e.name, name=e.name,
planned=e.amount, planned=e.amount,
applied=Decimal("0.00"), applied=Decimal("0.00"),
notes=e.notes,
origin_name=e.name, origin_name=e.name,
origin_planned=e.amount, origin_planned=e.amount,
source_entry_id=e.id, 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( def add_month_entry(
db: Session, db: Session,
month: Month, month: Month,
section: Section, section: Section,
name: str, name: str,
planned: Decimal, planned: Decimal,
notes: str | None = None,
) -> MonthEntry: ) -> MonthEntry:
entry = MonthEntry( entry = MonthEntry(
month_id=month.id, month_id=month.id,
@ -216,6 +225,7 @@ def add_month_entry(
name=name.strip(), name=name.strip(),
planned=planned, planned=planned,
applied=Decimal("0.00"), applied=Decimal("0.00"),
notes=_clean_notes(notes),
origin_name=None, origin_name=None,
origin_planned=None, origin_planned=None,
source_entry_id=None, source_entry_id=None,
@ -243,6 +253,9 @@ def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | No
return section return section
_NOTES_SENTINEL = object()
def update_month_entry( def update_month_entry(
db: Session, db: Session,
month: Month, month: Month,
@ -251,6 +264,7 @@ def update_month_entry(
name: str | None = None, name: str | None = None,
planned: Decimal | None = None, planned: Decimal | None = None,
applied: Decimal | None = None, applied: Decimal | None = None,
notes: str | None | object = _NOTES_SENTINEL,
) -> MonthEntry | None: ) -> MonthEntry | None:
entry = get_month_entry(db, month, entry_id) entry = get_month_entry(db, month, entry_id)
if entry is None: if entry is None:
@ -261,6 +275,8 @@ def update_month_entry(
entry.planned = planned entry.planned = planned
if applied is not None: if applied is not None:
entry.applied = applied entry.applied = applied
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

@ -107,13 +107,14 @@ def create_entry(
request: Request, request: Request,
name: str = Form(...), name: str = Form(...),
amount: str = Form(...), amount: str = Form(...),
notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
clean_name = name.strip() clean_name = name.strip()
if not clean_name: if not clean_name:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail="name is required")
parsed = _parse_amount(amount) 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) response = _render_section(request, db, section)
extras: list[HTMLResponse] = [ extras: list[HTMLResponse] = [
_render_zero(request, db), _render_zero(request, db),
@ -143,6 +144,19 @@ def remove_entry(
return _append_oob(response, *extras) 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) @router.post("/debt-target", response_class=HTMLResponse)
def update_debt_target( def update_debt_target(
request: Request, request: Request,

View file

@ -164,6 +164,7 @@ def add_month_entry(
request: Request, request: Request,
name: str = Form(...), name: str = Form(...),
planned: str = Form(...), planned: str = Form(...),
notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) month = _require_month(db, year_month)
@ -171,7 +172,7 @@ def add_month_entry(
if not clean_name: if not clean_name:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail="name is required")
month_service.add_month_entry( 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) db.refresh(month)
return _append_oob( return _append_oob(
@ -210,6 +211,7 @@ def update_month_entry(
name: str | None = Form(None), name: str | None = Form(None),
planned: str | None = Form(None), planned: str | None = Form(None),
applied: str | None = Form(None), applied: str | None = Form(None),
notes: str | None = Form(None),
db: Session = Depends(get_session), db: Session = Depends(get_session),
) -> HTMLResponse: ) -> HTMLResponse:
month = _require_month(db, year_month) 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") raise HTTPException(status_code=400, detail="name must not be empty")
parsed_planned = _parse_amount(planned) if planned is not None else None parsed_planned = _parse_amount(planned) if planned is not None else None
parsed_applied = _parse_amount(applied) if applied is not None else None parsed_applied = _parse_amount(applied) if applied is not None else None
updated = month_service.update_month_entry( kwargs: dict = dict(
db,
month,
entry_id,
name=clean_name, name=clean_name,
planned=parsed_planned, planned=parsed_planned,
applied=parsed_applied, 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: if updated is None:
raise HTTPException(status_code=404, detail="entry not found") raise HTTPException(status_code=404, detail="entry not found")
db.refresh(month) db.refresh(month)

View file

@ -87,14 +87,44 @@ def budget_zero(db: Session) -> Decimal:
return (total_income - total_non_income).quantize(Decimal("0.01")) return (total_income - total_non_income).quantize(Decimal("0.01"))
def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry: def add_entry(
entry = Entry(section=section, name=name.strip(), amount=amount) 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.add(entry)
db.commit() db.commit()
db.refresh(entry) db.refresh(entry)
return 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: def delete_entry(db: Session, entry_id: int) -> Entry | None:
entry = db.get(Entry, entry_id) entry = db.get(Entry, entry_id)
if entry is None: if entry is None:

View file

@ -111,6 +111,37 @@ tr.add-row td {
.muted { color: var(--muted); font-style: italic; } .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 { button.delete {
background: transparent; background: transparent;
border: none; border: none;

View file

@ -68,6 +68,22 @@
>&times;</button> >&times;</button>
</td> </td>
</tr> </tr>
<tr class="entry-notes-row">
<td colspan="4">
<input
class="notes-input"
type="text"
name="notes"
value="{{ row.entry.notes or '' }}"
placeholder="notes..."
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Notes for {{ row.entry.name }}"
>
</td>
</tr>
{% else %} {% else %}
<tr class="empty"><td colspan="4">No entries.</td></tr> <tr class="empty"><td colspan="4">No entries.</td></tr>
{% endfor %} {% endfor %}
@ -83,6 +99,7 @@
<input type="text" name="name" placeholder="Name" required> <input type="text" name="name" placeholder="Name" required>
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required> <input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
<button type="submit">Add</button> <button type="submit">Add</button>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form> </form>
</td> </td>
</tr> </tr>

View file

@ -22,6 +22,22 @@
>&times;</button> >&times;</button>
</td> </td>
</tr> </tr>
<tr class="entry-notes-row">
<td colspan="3">
<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 %} {% else %}
<tr class="empty"><td colspan="3">No entries yet.</td></tr> <tr class="empty"><td colspan="3">No entries yet.</td></tr>
{% endfor %} {% endfor %}
@ -37,6 +53,7 @@
<input type="text" name="name" placeholder="Name" required> <input type="text" name="name" placeholder="Name" required>
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required> <input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
<button type="submit">Add</button> <button type="submit">Add</button>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form> </form>
</td> </td>
</tr> </tr>

159
tests/test_notes.py Normal file
View file

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