From 517578f4f34737f378b3001924ff325cd8f4eb21 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:34:34 -0600 Subject: [PATCH 1/7] feat(db): add Posting model, derive MonthEntry.applied, seed opening balances Posting is a child of MonthEntry with occurred_on, amount, optional description and payee. Cascade delete so removing an entry wipes its ledger. Ordered on load by occurred_on DESC for readable UIs. MonthEntry.applied becomes a @property summing posting amounts. The stored applied column is dropped in the same migration. The migration walks existing month_entry rows: for every non-zero applied value, it inserts one opening-balance posting on the month's activated_at (or created_at) date with description "opening balance" and amount equal to the existing applied. Empty applied values get no opening posting. Closed months go through the same path; their totals stay intact via that single seeded row. Downgrade is symmetric: re-adds the column and populates from SUM(postings). Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- ...1c_add_posting_ledger_drop_month_entry_.py | 123 ++++++++++++++++++ src/quartermaster/models.py | 45 ++++++- 2 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py diff --git a/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py new file mode 100644 index 0000000..d45c445 --- /dev/null +++ b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py @@ -0,0 +1,123 @@ +"""add posting ledger, drop month_entry.applied + +Revision ID: cc60e7f73a1c +Revises: a4ec4f8f6e9f +Create Date: 2026-04-17 17:25:53.487094 + +Creates the posting table, seeds one "opening balance" posting per +existing month_entry whose applied value is non-zero, and drops the +applied column from month_entry. After this revision applied is +computed as SUM(posting.amount) over the entry's postings. +""" +from datetime import datetime, timezone +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cc60e7f73a1c" +down_revision: Union[str, Sequence[str], None] = "a4ec4f8f6e9f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create posting table + op.create_table( + "posting", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("month_entry_id", sa.Integer(), nullable=False), + sa.Column("occurred_on", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("description", sa.String(length=256), nullable=True), + sa.Column("payee", sa.String(length=128), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["month_entry_id"], ["month_entry.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_posting_month_entry_id"), + ["month_entry_id"], + unique=False, + ) + + # 2. Seed opening-balance postings from the existing month_entry.applied + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT me.id, me.applied, m.activated_at, m.created_at " + "FROM month_entry me JOIN month m ON m.id = me.month_id " + "WHERE me.applied IS NOT NULL AND me.applied != 0" + ) + ).fetchall() + now = datetime.now(timezone.utc).replace(tzinfo=None) + for row in rows: + me_id, applied, activated_at, created_at = row + occurred = activated_at or created_at + # Truncate the timestamp to a date for the DATE column + if hasattr(occurred, "date"): + occurred = occurred.date() + if isinstance(occurred, str): + # SQLite may give back an ISO string; take the date portion + occurred = occurred[:10] + conn.execute( + sa.text( + "INSERT INTO posting " + "(month_entry_id, occurred_on, amount, description, created_at, updated_at) " + "VALUES (:me_id, :occurred, :amount, :desc, :now, :now)" + ), + { + "me_id": me_id, + "occurred": occurred, + "amount": applied, + "desc": "opening balance", + "now": now, + }, + ) + + # 3. Drop the applied column from month_entry + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.drop_column("applied") + + +def downgrade() -> None: + # Add the applied column back with a default of 0.00 for NOT NULL + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "applied", + sa.NUMERIC(precision=10, scale=2), + server_default=sa.text("'0.00'"), + nullable=False, + ) + ) + + # Restore applied as SUM of postings + conn = op.get_bind() + conn.execute( + sa.text( + "UPDATE month_entry SET applied = (" + "SELECT COALESCE(SUM(amount), 0) FROM posting " + "WHERE posting.month_entry_id = month_entry.id" + ")" + ) + ) + + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_posting_month_entry_id")) + op.drop_table("posting") diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 6d8f25a..8c44d8f 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -1,11 +1,12 @@ from __future__ import annotations import enum -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( CheckConstraint, + Date, DateTime, Enum, ForeignKey, @@ -132,10 +133,6 @@ class MonthEntry(Base): ) name: Mapped[str] = mapped_column(String(128), nullable=False) planned: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) - applied: Mapped[Decimal] = mapped_column( - 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( @@ -155,6 +152,18 @@ class MonthEntry(Base): ) month: Mapped[Month] = relationship(back_populates="entries") + postings: Mapped[list["Posting"]] = relationship( + back_populates="entry", + cascade="all, delete-orphan", + order_by="Posting.occurred_on.desc(), Posting.id.desc()", + lazy="selectin", + ) + + @property + def applied(self) -> Decimal: + return sum((p.amount for p in self.postings), Decimal("0")).quantize( + Decimal("0.01") + ) class MonthDebtTarget(Base): @@ -177,3 +186,29 @@ class MonthDebtTarget(Base): month: Mapped[Month] = relationship(back_populates="target") entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined") + + +class Posting(Base): + __tablename__ = "posting" + + id: Mapped[int] = mapped_column(primary_key=True) + month_entry_id: Mapped[int] = mapped_column( + ForeignKey("month_entry.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + occurred_on: Mapped[date] = mapped_column(Date, nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + description: Mapped[str | None] = mapped_column(String(256), nullable=True) + payee: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + entry: Mapped[MonthEntry] = relationship(back_populates="postings") From 52bc52ec7fa3a4f4118715649fe7ea3923957d73 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:34:41 -0600 Subject: [PATCH 2/7] feat(ledger): service CRUD for postings and three new routes add_posting / update_posting / delete_posting all go through ensure_editable(month), so closed months reject posting mutations with the same lifecycle guard as every other mutation. Negative amounts are allowed for refunds / corrections. Dates are parsed as ISO (YYYY-MM-DD) but not constrained to the month for now. update_month_entry loses the applied keyword; the route no longer accepts an applied form field. applied is derived only from now on. Three new routes wire the ledger: POST /month/{ym}/entries/{entry_id}/postings POST /month/{ym}/postings/{posting_id} DELETE /month/{ym}/postings/{posting_id} Each returns the updated section partial plus OOB swaps for the zero widget and all four group totals, same pattern the existing mutations use. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/month_service.py | 87 +++++++++++++++++++++-- src/quartermaster/routes_month.py | 107 ++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 9 deletions(-) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 097adb1..9498345 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from dataclasses import dataclass from datetime import date, datetime, timezone -from decimal import Decimal +from decimal import Decimal, InvalidOperation from enum import Enum from sqlalchemy import select @@ -24,6 +24,7 @@ from quartermaster.models import ( MonthDebtTarget, MonthEntry, MonthState, + Posting, Section, ) @@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month: section=e.section, name=e.name, planned=e.amount, - applied=Decimal("0.00"), notes=e.notes, origin_name=e.name, origin_planned=e.amount, @@ -238,7 +238,6 @@ def add_month_entry( section=section, name=name.strip(), planned=planned, - applied=Decimal("0.00"), notes=_clean_notes(notes), origin_name=None, origin_planned=None, @@ -277,7 +276,6 @@ 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) @@ -287,8 +285,6 @@ def update_month_entry( entry.name = name.strip() if planned is not None: 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() @@ -296,6 +292,85 @@ def update_month_entry( return entry +def add_posting( + db: Session, + month: Month, + entry_id: int, + occurred_on: date, + amount: Decimal, + description: str | None = None, + payee: str | None = None, +) -> Posting | None: + entry = get_month_entry(db, month, entry_id) + if entry is None: + return None + posting = Posting( + month_entry_id=entry.id, + occurred_on=occurred_on, + amount=amount, + description=_clean_notes(description), + payee=_clean_notes(payee), + ) + db.add(posting) + db.commit() + db.refresh(posting) + return posting + + +def get_posting( + db: Session, month: Month, posting_id: int +) -> Posting | None: + posting = db.get(Posting, posting_id) + if posting is None: + return None + # Ensure posting belongs to this month via its entry + if posting.entry is None or posting.entry.month_id != month.id: + return None + return posting + + +_POSTING_SENTINEL = object() + + +def update_posting( + db: Session, + month: Month, + posting_id: int, + *, + occurred_on: date | None = None, + amount: Decimal | None = None, + description: str | None | object = _POSTING_SENTINEL, + payee: str | None | object = _POSTING_SENTINEL, +) -> Posting | None: + posting = get_posting(db, month, posting_id) + if posting is None: + return None + if occurred_on is not None: + posting.occurred_on = occurred_on + if amount is not None: + posting.amount = amount + if description is not _POSTING_SENTINEL: + posting.description = _clean_notes(description) # type: ignore[arg-type] + if payee is not _POSTING_SENTINEL: + posting.payee = _clean_notes(payee) # type: ignore[arg-type] + db.commit() + db.refresh(posting) + return posting + + +def delete_posting( + db: Session, month: Month, posting_id: int +) -> MonthEntry | None: + """Delete a posting; return the parent entry so callers can re-render its section.""" + posting = get_posting(db, month, posting_id) + if posting is None: + return None + entry = posting.entry + db.delete(posting) + db.commit() + return entry + + def get_month_target(db: Session, month: Month) -> MonthDebtTarget: if month.target is not None: return month.target diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index e64fc75..8ff228b 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal, InvalidOperation from fastapi import APIRouter, Depends, Form, HTTPException, Request @@ -242,7 +243,6 @@ def update_month_entry( request: Request, 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: @@ -253,11 +253,9 @@ def update_month_entry( if not clean_name: 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 kwargs: dict = dict( name=clean_name, planned=parsed_planned, - applied=parsed_applied, ) if notes is not None: kwargs["notes"] = notes @@ -320,6 +318,109 @@ def reopen_month( ) +def _parse_date(raw: str) -> date: + try: + return date.fromisoformat(raw.strip()) + except (ValueError, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="date must be YYYY-MM-DD" + ) from exc + + +def _parse_signed_amount(raw: str) -> Decimal: + try: + amount = Decimal(raw.strip()) + except (InvalidOperation, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="amount must be numeric" + ) from exc + return amount.quantize(Decimal("0.01")) + + +@router.post("/entries/{entry_id}/postings", response_class=HTMLResponse) +def create_posting( + year_month: str, + entry_id: int, + request: Request, + occurred_on: str = Form(...), + amount: str = Form(...), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + posting = month_service.add_posting( + db, + month, + entry_id, + _parse_date(occurred_on), + _parse_signed_amount(amount), + description=description, + payee=payee, + ) + if posting is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + entry = month_service.get_month_entry(db, month, entry_id) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.post("/postings/{posting_id}", response_class=HTMLResponse) +def update_posting( + year_month: str, + posting_id: int, + request: Request, + occurred_on: str | None = Form(None), + amount: str | None = Form(None), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + kwargs: dict = {} + if occurred_on is not None: + kwargs["occurred_on"] = _parse_date(occurred_on) + if amount is not None: + kwargs["amount"] = _parse_signed_amount(amount) + if description is not None: + kwargs["description"] = description + if payee is not None: + kwargs["payee"] = payee + updated = month_service.update_posting(db, month, posting_id, **kwargs) + if updated is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + entry = updated.entry + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.delete("/postings/{posting_id}", response_class=HTMLResponse) +def delete_posting( + year_month: str, + posting_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + entry = month_service.delete_posting(db, month, posting_id) + if entry is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + @router.post("/target", response_class=HTMLResponse) def update_month_target( year_month: str, From cca05fe9fc17e768e5afad26a5ee11cdd076409f Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:34:48 -0600 Subject: [PATCH 3/7] feat(ledger): expandable entry rows with transactions table and add form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each month entry becomes a
block. The summary is the same dense row (name, planned, applied, delete) plus a leading caret and an applied cell that shows the transaction count ("$412.33 · 7 txns") when postings exist. Expansion adds no horizontal space. Expanded body holds: the entry's notes input, a transactions table with date / description / payee / amount / delete per posting, and an inline add-transaction form (date, description, payee, amount, submit). Every field is HTMX-wired so editing any cell triggers the section partial re-render with fresh derived totals. Closed month: name / planned / notes / posting fields all collapse to read-only spans, delete buttons and add forms are omitted. The existing editable flag controls the branching. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/static/app.css | 289 ++++++++++++++++++ .../templates/partials/month_section.html | 259 +++++++++++----- 2 files changed, 469 insertions(+), 79 deletions(-) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 2574f91..1a4dd69 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -675,6 +675,295 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { padding: 0.15rem 0.4rem; } +/* =============== MONTH ENTRIES — details-based layout =============== */ + +.month-entries { + display: flex; + flex-direction: column; +} + +.entry-block { + border-bottom: 1px dotted var(--rule); + margin: 0; +} + +.entry-block > summary { + list-style: none; + cursor: pointer; + user-select: none; + display: grid; + grid-template-columns: 0.9rem minmax(0, 1fr) 5.5rem 5.5rem 1.2rem; + gap: 0.6rem; + align-items: baseline; + padding: 0.32rem 0.25rem 0.36rem; + position: relative; +} +.entry-block > summary::-webkit-details-marker { display: none; } +.entry-block > summary:hover { background: var(--paper-stripe); } + +/* Progress bar on the summary row */ +.entry-block > summary::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-block > summary.state-edited::after { background: var(--accent); opacity: 0.85; } +.entry-block > summary.state-new_in_month::after { background: var(--indigo); opacity: 0.55; } + +/* Caret: rotates on [open] */ +.entry-block .caret { + display: inline-block; + width: 0.7rem; + position: relative; + transform: rotate(0deg); + transition: transform 0.15s ease; + align-self: center; +} +.entry-block .caret::before { + content: "▸"; + font-size: 0.7rem; + color: var(--muted); + display: inline-block; + line-height: 1; +} +.entry-block[open] > summary .caret { transform: rotate(90deg); } + +/* Entry row cells */ +.entry-block .entry-name { + font-weight: 500; + font-size: 1rem; + min-width: 0; + overflow: hidden; +} +.entry-block .entry-name input, +.entry-block .entry-amount 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-block .entry-name input:hover, +.entry-block .entry-amount input:hover { border-bottom-color: var(--rule); } +.entry-block .entry-name input:focus, +.entry-block .entry-amount input:focus { border-bottom-color: var(--ink); } + +.entry-block .entry-amount { + font-weight: 500; + font-size: 1rem; + text-align: right; + font-feature-settings: "lnum" 1, "tnum" 1; + min-width: 0; +} +.entry-block .entry-amount input { + text-align: right; + font-variant-numeric: tabular-nums; +} +.entry-block .entry-amount.planned input { color: var(--muted); font-weight: 400; } + +.entry-block .applied-cell { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 0.3rem; +} +.entry-block .applied-cell .value { + font-weight: 500; +} +.entry-block .applied-cell .count { + font-family: var(--sans); + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: lowercase; + color: var(--muted); +} + +.entry-block .entry-actions button.delete { + font-size: 1.1rem; + 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; +} +.entry-block > summary:hover .entry-actions button.delete { opacity: 1; } +.entry-block .entry-actions button.delete:hover { color: var(--accent); } + +/* Expanded body */ +.entry-block .entry-body { + padding: 0.5rem 1.6rem 0.75rem; + background: var(--paper-soft); + border-top: 1px solid var(--rule-soft); +} + +.entry-block .entry-notes { + margin-bottom: 0.5rem; +} +.entry-block .entry-notes input { + font-family: var(--sans); + font-style: italic; + font-size: 0.85rem; + color: var(--ink); + width: 100%; + background: transparent; + border: none; + border-bottom: 1px dashed var(--rule); + padding: 0.15rem 0; + outline: none; +} +.entry-block .entry-notes input:focus { border-bottom-color: var(--ink); } +.entry-block .entry-notes.readonly { + font-family: var(--sans); + font-style: italic; + font-size: 0.85rem; + color: var(--muted); +} + +/* Transactions table */ +table.transactions { + width: 100%; + border-collapse: collapse; + font-family: var(--sans); + font-size: 0.88rem; + margin-bottom: 0.4rem; +} +table.transactions thead th { + text-align: left; + font-weight: 600; + font-size: 0.66rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--muted); + padding: 0.2rem 0.3rem 0.25rem; + border-bottom: 1px solid var(--rule); +} +table.transactions th.col-amount, +table.transactions td.col-amount { text-align: right; width: 5.5rem; } +table.transactions th.col-date, +table.transactions td.col-date { width: 7.5rem; } +table.transactions th.col-actions, +table.transactions td.col-actions { width: 1.2rem; } + +table.transactions td { + padding: 0.2rem 0.3rem; + border-bottom: 1px dotted var(--rule-soft); +} +table.transactions tr.posting:hover { background: var(--paper); } +table.transactions 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; +} +table.transactions input:hover { border-bottom-color: var(--rule); } +table.transactions input:focus { border-bottom-color: var(--ink); } +table.transactions input[type="number"] { + text-align: right; + font-variant-numeric: tabular-nums; +} +table.transactions input[type="date"] { + font-family: var(--sans); + color: var(--muted); +} +table.transactions tr.empty td { + color: var(--muted); + font-style: italic; + text-align: center; + padding: 0.5rem; +} + +table.transactions td .readonly { + color: var(--ink); +} + +table.transactions button.delete { + font-size: 1rem; + color: var(--rule); + background: none; + border: none; + cursor: pointer; + padding: 0; + opacity: 0; + transition: color 0.12s ease, opacity 0.12s ease; +} +table.transactions tr:hover button.delete { opacity: 1; } +table.transactions button.delete:hover { color: var(--accent); } + +/* Add-posting form */ +form.add-posting-form { + display: grid; + grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 1fr) 5.5rem auto; + gap: 0.4rem; + align-items: center; + padding: 0.35rem 0.3rem 0.2rem; + border-top: 1px dashed var(--rule); +} +form.add-posting-form input { + font: inherit; + font-size: 0.88rem; + padding: 0.2rem 0.4rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + outline: none; + transition: border-color 0.12s; +} +form.add-posting-form input[type="number"] { text-align: right; font-variant-numeric: tabular-nums; } +form.add-posting-form input:focus { border-color: var(--ink); } +form.add-posting-form button[type="submit"] { + font-family: var(--sans); + font-weight: 600; + font-size: 0.72rem; + padding: 0.25rem 0.75rem; + border: 1px solid var(--ink); + background: var(--paper-soft); + color: var(--ink); + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +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.6rem 4.6rem 1rem; + gap: 0.4rem; + } + .entry-block .entry-body { padding: 0.5rem 0.75rem 0.6rem; } + form.add-posting-form { + grid-template-columns: 1fr 1fr; + } + form.add-posting-form input[type="date"], + form.add-posting-form input[type="number"], + form.add-posting-form button[type="submit"] { grid-column: 1 / -1; } +} + /* Disabled inputs (closed month) */ input[disabled], select[disabled], diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html index 8868b2c..757ab49 100644 --- a/src/quartermaster/templates/partials/month_section.html +++ b/src/quartermaster/templates/partials/month_section.html @@ -2,68 +2,61 @@

{{ section.label }}

- ${{ '%.2f' | format(section.total_applied) }} + ${{ '{:,.2f}'.format(section.total_applied) }} / - ${{ '%.2f' | format(section.total_planned) }} + ${{ '{:,.2f}'.format(section.total_planned) }}
- - - - - - - - - - - {% for row in section.rows %} - - - - - - - - - - {% else %} - - {% endfor %} - {% if editable %} - - - - {% endif %} - -
NamePlannedApplied
- + {% for row in section.rows %} + {% set applied = row.entry.applied %} +
+ + + + {% if editable %} + + aria-label="Name" + > + {% else %} + {{ row.entry.name }} + {% endif %} {% if row.state.value == 'edited' %} modified {% elif row.state.value == 'new_in_month' %} new this month {% endif %} -
- + + {% if editable %} + - - - + aria-label="Planned" + > + {% else %} + ${{ '{:,.2f}'.format(row.entry.planned) }} + {% endif %} + + + ${{ '{:,.2f}'.format(applied) }} + {% if row.entry.postings|length > 0 %} + · {{ row.entry.postings|length }} txn{% if row.entry.postings|length != 1 %}s{% endif %} + {% endif %} + + {% if editable %} {% endif %} -
- + +
+ {% if editable %} +
+ -
No entries.
+ aria-label="Notes" + > + + {% elif row.entry.notes %} +
{{ row.entry.notes }}
+ {% endif %} + + + + + + + + + + + + {% for posting in row.entry.postings %} + + + + + + + + {% else %} + + {% endfor %} + +
DateDescriptionPayeeAmount
+ {% if editable %} + + {% else %} + {{ posting.occurred_on.isoformat() }} + {% endif %} + + {% if editable %} + + {% else %} + {{ posting.description or '' }} + {% endif %} + + {% if editable %} + + {% else %} + {{ posting.payee or '' }} + {% endif %} + + {% if editable %} + + {% else %} + ${{ '{:,.2f}'.format(posting.amount) }} + {% endif %} + + {% if editable %} + + {% endif %} +
No transactions yet.
+ {% if editable %}
- - - - + + + + +
-
+ {% endif %} + +
+ {% else %} +
No entries.
+ {% endfor %} + {% if editable %} +
+
+ + + + +
+
+ {% endif %} + From e80a3508b6770836d2a55059d47359d245b50ce3 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:34:53 -0600 Subject: [PATCH 4/7] test: cover posting CRUD and update existing tests to use the ledger New test_postings.py walks service and route layers: add sums into applied, negatives are allowed, update and delete round-trip, entry deletion cascades postings, order is desc by date, update_month_entry rejects the removed applied kwarg. Route tests assert HTTP behaviour, invalid-date rejection, closed-month lock, tone flip after a posting, and the "N txns" count badge renders. Existing tests that previously set applied via update_month_entry or the entries route now use add_posting or POST to /postings. Format assertions updated to match the new thousands-separator number rendering and the replaced entry-notes-row markup. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_groups.py | 7 +- tests/test_month_lifecycle.py | 44 ++++--- tests/test_month_routes.py | 15 +-- tests/test_month_service.py | 13 +- tests/test_notes.py | 3 +- tests/test_postings.py | 226 ++++++++++++++++++++++++++++++++++ tests/test_zero_amount.py | 20 +-- 7 files changed, 291 insertions(+), 37 deletions(-) create mode 100644 tests/test_postings.py diff --git a/tests/test_groups.py b/tests/test_groups.py index d877f7a..7bf06bf 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal from quartermaster import month_service, service @@ -65,10 +66,10 @@ def test_month_group_views_planned_and_applied(db): service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00")) service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00")) month = month_service.create_month(db, "2026-04") - # seed applied values + # seed applied values via a posting rent = next(e for e in month.entries if e.origin_name == "Rent") - month_service.update_month_entry( - db, month, rent.id, applied=Decimal("1200.00") + month_service.add_posting( + db, month, rent.id, date.today(), Decimal("1200.00"), description="test" ) db.refresh(month) views = {v.group: v for v in month_service.month_group_views(month)} diff --git a/tests/test_month_lifecycle.py b/tests/test_month_lifecycle.py index f75ee66..24589c3 100644 --- a/tests/test_month_lifecycle.py +++ b/tests/test_month_lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal import pytest @@ -16,10 +17,10 @@ def _seed(db, balance_to_zero=False): service.add_entry(db, Section.other, "Misc", Decimal("60.00")) month = month_service.create_month(db, "2026-04") if balance_to_zero: - # Apply income fully and distribute applied to match planned exactly + # Post one transaction per entry matching the planned amount for entry in month.entries: - month_service.update_month_entry( - db, month, entry.id, applied=entry.planned + month_service.add_posting( + db, month, entry.id, date.today(), entry.planned, description="seed" ) db.refresh(month) return month @@ -61,8 +62,8 @@ def test_close_requires_zero_balance(db): # applied zero is 0 - 0 = 0, but income applied is 0 so # (0 income - 0 others) == 0 actually. Force a non-zero by applying to food. food = next(e for e in month.entries if e.origin_name == "Groceries") - month_service.update_month_entry( - db, month, food.id, applied=Decimal("50.00") + month_service.add_posting( + db, month, food.id, date.today(), Decimal("50.00"), description="seed" ) db.refresh(month) with pytest.raises(month_service.MonthLifecycleError) as exc: @@ -128,13 +129,16 @@ def _seed_via_api(client, balance_to_zero=False): if balance_to_zero: # entry ids 1,2,3 in that order client.post( - "/month/2026-04/entries/1", data={"applied": "1000.00"} + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-01", "amount": "1000.00"}, ) client.post( - "/month/2026-04/entries/2", data={"applied": "600.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "600.00"}, ) client.post( - "/month/2026-04/entries/3", data={"applied": "400.00"} + "/month/2026-04/entries/3/postings", + data={"occurred_on": "2026-04-15", "amount": "400.00"}, ) @@ -148,8 +152,11 @@ def test_activate_route(client): def test_close_route_rejects_unbalanced(client): _seed_via_api(client) client.post("/month/2026-04/activate") - # apply some expense without income to produce an unbalanced state - client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + # post an expense without income to produce an unbalanced state + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "100.00"}, + ) response = client.post("/month/2026-04/close") assert response.status_code == 400 assert "0.00" in response.json()["detail"] @@ -182,7 +189,8 @@ def test_mutations_rejected_on_closed_month(client): ) assert add_response.status_code == 400 update_response = client.post( - "/month/2026-04/entries/1", data={"applied": "1200.00"} + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-15", "amount": "50.00"}, ) assert update_response.status_code == 400 delete_response = client.delete("/month/2026-04/entries/1") @@ -201,8 +209,11 @@ def test_month_page_shows_planning_badge_and_activate_button(client): def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client): _seed_via_api(client) client.post("/month/2026-04/activate") - # force imbalance - client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + # force imbalance via a posting + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "100.00"}, + ) response = client.get("/month/2026-04") assert response.status_code == 200 assert "state-active" in response.text @@ -237,7 +248,10 @@ def test_closed_month_inputs_are_disabled(client): client.post("/month/2026-04/activate") client.post("/month/2026-04/close") response = client.get("/month/2026-04") - # name inputs rendered with disabled - assert "disabled" in response.text + # Editable inputs are replaced by readonly spans on closed months, + # so the name/planned inputs should not appear. + assert 'name="planned"' not in response.text + assert 'name="name"' not in response.text + assert "class=\"readonly\"" in response.text # delete buttons not rendered assert "Delete Paycheck" not in response.text diff --git a/tests/test_month_routes.py b/tests/test_month_routes.py index a9b176d..75fc06d 100644 --- a/tests/test_month_routes.py +++ b/tests/test_month_routes.py @@ -38,23 +38,24 @@ def test_created_month_renders_snapshot(client): assert "Paycheck" in response.text assert "Rent" in response.text assert "Card A" in response.text - # totals: applied / planned - assert "$2500.00" in response.text - assert "$1200.00" in response.text + # totals: applied / planned rendered with thousands separators + assert "$2,500.00" in response.text + assert "$1,200.00" in response.text def test_applied_update_returns_section_partial(client): _seed_budget_via_api(client) client.post("/month/2026-04/create") - # fetch month page, identify rent month_entry id = 2 (after income id 1) + # rent is month_entry id=2 (after income id=1). Post a transaction so + # applied accumulates to 1200. response = client.post( - "/month/2026-04/entries/2", data={"applied": "1200.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200.00"}, ) assert response.status_code == 200 # rent is in fixed_bill section; total_applied should now include 1200 assert 'id="section-fixed_bill"' in response.text - # two $1200.00 occurrences: total_applied and planned (same amount) - assert response.text.count("$1200.00") >= 2 + assert "$1,200.00" in response.text or "$1200.00" in response.text def test_name_edit_flips_to_modified(client): diff --git a/tests/test_month_service.py b/tests/test_month_service.py index 11a69e0..837e6f2 100644 --- a/tests/test_month_service.py +++ b/tests/test_month_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal import pytest @@ -8,6 +9,13 @@ from quartermaster import month_service, service from quartermaster.models import Section +def _apply(db, month, entry_id, amount): + """Record an applied amount against a month entry by posting.""" + return month_service.add_posting( + db, month, entry_id, date.today(), Decimal(str(amount)), description="test" + ) + + def _seed_budget(db): income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) rent = service.add_entry( @@ -96,9 +104,8 @@ def test_applied_update_changes_totals(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") rent = next(e for e in month.entries if e.origin_name == "Rent") - month_service.update_month_entry( - db, month, rent.id, applied=Decimal("1200.00") - ) + _apply(db, month, rent.id, "1200.00") + db.refresh(month) view = month_service.section_view(month, Section.fixed_bill, "Fixed") assert view.total_applied == Decimal("1200.00") assert view.total_planned == Decimal("1200.00") diff --git a/tests/test_notes.py b/tests/test_notes.py index 498a526..e652528 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -155,5 +155,6 @@ def test_month_page_renders_notes_inputs(client): client.post("/month/2026-04/create") response = client.get("/month/2026-04") assert response.status_code == 200 - assert "entry-notes-row" in response.text + # notes input lives inside the expandable entry body now + assert 'name="notes"' in response.text assert "auto-pay" in response.text diff --git a/tests/test_postings.py b/tests/test_postings.py new file mode 100644 index 0000000..20f021a --- /dev/null +++ b/tests/test_postings.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +from quartermaster import month_service, service +from quartermaster.models import Posting, Section + + +def _seed_month(db, section=Section.fixed_bill, name="Rent", planned="1200"): + service.add_entry(db, Section.income, "Paycheck", Decimal("2500")) + service.add_entry(db, section, name, Decimal(planned)) + return month_service.create_month(db, "2026-04") + + +def test_add_posting_sums_into_applied(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("600"), description="first half" + ) + month_service.add_posting( + db, month, rent.id, date(2026, 4, 15), Decimal("600"), description="second half" + ) + db.refresh(rent) + assert rent.applied == Decimal("1200.00") + assert len(rent.postings) == 2 + + +def test_empty_entry_applied_is_zero(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + assert rent.applied == Decimal("0.00") + assert rent.postings == [] + + +def test_negative_posting_allowed(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("1200") + ) + month_service.add_posting( + db, month, rent.id, date(2026, 4, 3), Decimal("-50"), description="refund" + ) + db.refresh(rent) + assert rent.applied == Decimal("1150.00") + + +def test_update_posting(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + posting = month_service.add_posting( + db, month, rent.id, date(2026, 4, 1), Decimal("1100") + ) + month_service.update_posting( + db, month, posting.id, + amount=Decimal("1200"), + description="corrected", + occurred_on=date(2026, 4, 3), + ) + db.refresh(rent) + assert rent.applied == Decimal("1200.00") + updated = rent.postings[0] + assert updated.description == "corrected" + assert updated.occurred_on == date(2026, 4, 3) + + +def test_delete_posting(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + p1 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + p2 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + entry = month_service.delete_posting(db, month, p1.id) + db.refresh(rent) + assert entry.id == rent.id + assert rent.applied == Decimal("600.00") + assert len(rent.postings) == 1 + assert rent.postings[0].id == p2.id + + +def test_delete_entry_cascades_postings(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) + # Postings count before deletion + all_postings = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() + assert all_postings == 2 + month_service.delete_month_entry(db, month, rent.id) + db.expire_all() + remaining = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() + assert remaining == 0 + + +def test_postings_ordered_desc(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.add_posting(db, month, rent.id, date(2026, 4, 1), Decimal("100")) + month_service.add_posting(db, month, rent.id, date(2026, 4, 15), Decimal("200")) + month_service.add_posting(db, month, rent.id, date(2026, 4, 8), Decimal("150")) + db.refresh(rent) + dates = [p.occurred_on for p in rent.postings] + assert dates == [date(2026, 4, 15), date(2026, 4, 8), date(2026, 4, 1)] + + +def test_update_month_entry_rejects_applied_kwarg(db): + month = _seed_month(db) + rent = next(e for e in month.entries if e.origin_name == "Rent") + with pytest.raises(TypeError): + month_service.update_month_entry( + db, month, rent.id, applied=Decimal("500") + ) + + +# ----- Route-level tests ----- + +def _seed_via_api(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500"}, + ) + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200"}, + ) + client.post("/month/2026-04/create") + + +def test_add_posting_route(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={ + "occurred_on": "2026-04-01", + "amount": "1200", + "description": "April rent", + "payee": "Landlord", + }, + ) + assert response.status_code == 200 + assert "April rent" in response.text + assert "Landlord" in response.text + + +def test_update_posting_route(client): + _seed_via_api(client) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1100"}, + ) + # posting id is 1 (first created) + response = client.post( + "/month/2026-04/postings/1", + data={"amount": "1200", "description": "fixed amount"}, + ) + assert response.status_code == 200 + assert "fixed amount" in response.text + + +def test_delete_posting_route(client): + _seed_via_api(client) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + response = client.delete("/month/2026-04/postings/1") + assert response.status_code == 200 + # The section partial should render with no postings and applied back to $0 + assert "$0.00" in response.text + + +def test_posting_routes_reject_invalid_date(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "not-a-date", "amount": "100"}, + ) + assert response.status_code == 400 + + +def test_posting_rejected_on_closed_month(client): + _seed_via_api(client) + # Post balancing transactions so the month can close + client.post( + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + client.post("/month/2026-04/activate") + close = client.post("/month/2026-04/close") + assert close.status_code == 204 + # Now try to add a new posting on the closed month + response = client.post( + "/month/2026-04/entries/1/postings", + data={"occurred_on": "2026-04-20", "amount": "1"}, + ) + assert response.status_code == 400 + + +def test_applied_update_via_posting_flips_zero_tone(client): + _seed_via_api(client) + response = client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200"}, + ) + assert response.status_code == 200 + # With only rent applied, applied zero = 0 - 1200 = -1200 -> negative + assert "tone-negative" in response.text + + +def test_posting_count_badge_renders(client): + _seed_via_api(client) + for i in range(3): + client.post( + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "400"}, + ) + page = client.get("/month/2026-04") + assert page.status_code == 200 + assert "3 txns" in page.text diff --git a/tests/test_zero_amount.py b/tests/test_zero_amount.py index 1c3d53e..fd4c90a 100644 --- a/tests/test_zero_amount.py +++ b/tests/test_zero_amount.py @@ -1,11 +1,18 @@ from __future__ import annotations +from datetime import date from decimal import Decimal from quartermaster import month_service, service from quartermaster.models import Section +def _apply(db, month, entry_id, amount): + return month_service.add_posting( + db, month, entry_id, date.today(), Decimal(str(amount)), description="test" + ) + + def test_zero_tone_classification(): assert service.zero_tone(Decimal("0")) == "zero" assert service.zero_tone(Decimal("0.00")) == "zero" @@ -66,12 +73,8 @@ def test_month_zero_reflects_applied_updates(db): month = month_service.create_month(db, "2026-04") income_entry = next(e for e in month.entries if e.origin_name == "Paycheck") rent_entry = next(e for e in month.entries if e.origin_name == "Rent") - month_service.update_month_entry( - db, month, income_entry.id, applied=Decimal("2500.00") - ) - month_service.update_month_entry( - db, month, rent_entry.id, applied=Decimal("1200.00") - ) + _apply(db, month, income_entry.id, "2500.00") + _apply(db, month, rent_entry.id, "1200.00") db.refresh(month) z = month_service.month_zero(month) # applied income 2500 - applied (1200 + 0) = 1300 @@ -117,9 +120,10 @@ def test_month_entry_update_returns_zero_widget_oob(client): data={"name": "Rent", "amount": "1200.00"}, ) client.post("/month/2026-04/create") - # updating rent applied should bring the month zero widget back with new values + # posting rent applied via the ledger should bring the month zero widget back with new values response = client.post( - "/month/2026-04/entries/2", data={"applied": "1200.00"} + "/month/2026-04/entries/2/postings", + data={"occurred_on": "2026-04-01", "amount": "1200.00"}, ) assert response.status_code == 200 assert 'id="zero-widget"' in response.text From 84d77b804c812d963aa38fb4bf61844ec7568685 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:40:02 -0600 Subject: [PATCH 5/7] fix(ledger): give the applied cell room for the transaction count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the applied column from 5.5rem to 9rem so "\$134.32 · 7 txns" fits on one line. Add white-space: nowrap to the cell and its children as belt-and-braces. Mobile breakpoint gets 7rem with the count text shrunk, still single-line. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/static/app.css | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 1a4dd69..6a986cc 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -692,7 +692,7 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { cursor: pointer; user-select: none; display: grid; - grid-template-columns: 0.9rem minmax(0, 1fr) 5.5rem 5.5rem 1.2rem; + grid-template-columns: 0.9rem minmax(0, 1fr) 5.5rem 9rem 1.2rem; gap: 0.6rem; align-items: baseline; padding: 0.32rem 0.25rem 0.36rem; @@ -774,17 +774,21 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { display: flex; justify-content: flex-end; align-items: baseline; - gap: 0.3rem; + gap: 0.35rem; + white-space: nowrap; + flex-wrap: nowrap; } .entry-block .applied-cell .value { font-weight: 500; + white-space: nowrap; } .entry-block .applied-cell .count { font-family: var(--sans); font-size: 0.66rem; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: lowercase; color: var(--muted); + white-space: nowrap; } .entry-block .entry-actions button.delete { @@ -952,10 +956,11 @@ form.add-posting-form button[type="submit"]:hover { background: var(--ink); colo @media (max-width: 640px) { .entry-block > summary { - grid-template-columns: 0.9rem minmax(0, 1fr) 4.6rem 4.6rem 1rem; + grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem; gap: 0.4rem; } .entry-block .entry-body { padding: 0.5rem 0.75rem 0.6rem; } + .entry-block .applied-cell .count { font-size: 0.58rem; letter-spacing: 0.1em; } form.add-posting-form { grid-template-columns: 1fr 1fr; } From 2f571b236e71117b9e4f41183d9be517208de478 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:42:35 -0600 Subject: [PATCH 6/7] fix(notes): make empty notes rows clickable on the budget page The notes row was display:none when empty and revealed on entry-row hover. Moving the cursor down to click the input left the entry row and immediately hid the notes again, a classic hover-gap. Fix by always rendering the row with a subtle 0.55 opacity when empty and bumping it to 1.0 on its own hover or focus. Now the input is always reachable without a hover dance. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/static/app.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 6a986cc..a3cbf68 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -523,11 +523,10 @@ tr.entry-notes-row input.notes-input::placeholder { color: var(--rule); font-style: italic; } -tr.entry-notes-row:has(input:placeholder-shown) { display: none; } -tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) { - display: block; - opacity: 0.5; -} +/* 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); From 1384fae5e480c29138b0f63f7593316fee146c95 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 17:46:39 -0600 Subject: [PATCH 7/7] fix(ui): condense add-entry to a compact disclosure trigger Every section used to end with a wide horizontal add form (name input, amount input, button, notes input spanning the row). It took up more horizontal real estate than the entries themselves. Now the form is wrapped in a
whose summary is a small tracked-caps link ("+ add fixed amount bills"). Click to reveal the same form below; HTMX submission still resets and hides on success. Same treatment on the budget page sections and the month page sections. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/static/app.css | 34 +++++++++++++++++++ .../templates/partials/month_section.html | 27 ++++++++------- .../templates/partials/section.html | 27 ++++++++------- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index a3cbf68..328bbfe 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -421,6 +421,40 @@ tr.add-row td { display: block; } +/* Add-entry disclosure: collapsed trigger, expanded form */ +details.add-entry { margin: 0; } +details.add-entry > summary { + list-style: none; + cursor: pointer; + user-select: none; + display: inline-block; + padding: 0.1rem 0.25rem; +} +details.add-entry > summary::-webkit-details-marker { display: none; } +details.add-entry .add-trigger { + font-family: var(--sans); + font-size: 0.78rem; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.18em; + text-transform: uppercase; + border-bottom: 1px dashed transparent; + padding-bottom: 1px; + transition: color 0.12s ease, border-color 0.12s ease; +} +details.add-entry > summary:hover .add-trigger { + color: var(--ink); + border-bottom-color: var(--rule); +} +details.add-entry[open] > summary .add-trigger { + color: var(--ink); + border-bottom-color: var(--ink); +} +details.add-entry > .add-form, +details.add-entry > .month-add-form { + margin-top: 0.45rem; +} + .entry-name { font-family: var(--sans); font-weight: 500; diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html index 757ab49..4f1b3f0 100644 --- a/src/quartermaster/templates/partials/month_section.html +++ b/src/quartermaster/templates/partials/month_section.html @@ -203,18 +203,21 @@ {% endfor %} {% if editable %}
-
- - - - -
+
+ + add {{ section.label|lower }} +
+ + + + +
+
{% endif %} diff --git a/src/quartermaster/templates/partials/section.html b/src/quartermaster/templates/partials/section.html index f39fda4..1b45f7d 100644 --- a/src/quartermaster/templates/partials/section.html +++ b/src/quartermaster/templates/partials/section.html @@ -44,18 +44,21 @@ {% endfor %} -
- - - - -
+
+ + add {{ section.label|lower }} +
+ + + + +
+