From 58faa9dfe98ee27fa8e0ce9c86bc8b8900babf13 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:39:50 -0600 Subject: [PATCH 1/5] feat(db): add Month, MonthEntry, and MonthDebtTarget models with migration A month is a snapshot of the budget. MonthEntry holds the copied planned amount plus applied and origin_name/origin_planned so the UI can mark edited rows. source_entry_id links back to the budget but is nullable with ON DELETE SET NULL, so deleting a budget row after snapshot leaves the month intact. MonthDebtTarget is one row per month via CASCADE from month. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../03ebe3c07262_add_month_snapshot_tables.py | 73 +++++++++++++++++ src/quartermaster/models.py | 80 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 alembic/versions/03ebe3c07262_add_month_snapshot_tables.py diff --git a/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py b/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py new file mode 100644 index 0000000..d2c226f --- /dev/null +++ b/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py @@ -0,0 +1,73 @@ +"""add month snapshot tables + +Revision ID: 03ebe3c07262 +Revises: f1ccdc4bc1bf +Create Date: 2026-04-17 11:33:45.853510 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '03ebe3c07262' +down_revision: Union[str, Sequence[str], None] = 'f1ccdc4bc1bf' +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! ### + op.create_table('month', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('year_month', sa.String(length=7), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year_month') + ) + op.create_table('month_entry', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('month_id', sa.Integer(), nullable=False), + sa.Column('section', sa.Enum('income', 'fixed_bill', 'debt_minimum', 'food', 'subscription', 'other', name='section', native_enum=False, length=32), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('planned', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('applied', sa.Numeric(precision=10, scale=2), server_default='0.00', nullable=False), + sa.Column('origin_name', sa.String(length=128), nullable=True), + sa.Column('origin_planned', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('source_entry_id', sa.Integer(), 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_id'], ['month.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['source_entry_id'], ['entry.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('month_id', 'id') + ) + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_month_entry_month_id'), ['month_id'], unique=False) + batch_op.create_index(batch_op.f('ix_month_entry_section'), ['section'], unique=False) + + op.create_table('month_debt_target', + sa.Column('month_id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('month_entry_id', sa.Integer(), nullable=True), + 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='SET NULL'), + sa.ForeignKeyConstraint(['month_id'], ['month.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('month_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('month_debt_target') + with op.batch_alter_table('month_entry', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_month_entry_section')) + batch_op.drop_index(batch_op.f('ix_month_entry_month_id')) + + op.drop_table('month_entry') + op.drop_table('month') + # ### end Alembic commands ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 6e155f2..8fb87c1 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( ForeignKey, Numeric, String, + UniqueConstraint, func, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -75,3 +76,82 @@ class DebtTarget(Base): ) entry: Mapped[Entry | None] = relationship(Entry, lazy="joined") + + +class Month(Base): + __tablename__ = "month" + + id: Mapped[int] = mapped_column(primary_key=True) + year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + entries: Mapped[list["MonthEntry"]] = relationship( + back_populates="month", cascade="all, delete-orphan" + ) + target: Mapped["MonthDebtTarget | None"] = relationship( + back_populates="month", + cascade="all, delete-orphan", + uselist=False, + lazy="joined", + ) + + +class MonthEntry(Base): + __tablename__ = "month_entry" + __table_args__ = (UniqueConstraint("month_id", "id"),) + + id: Mapped[int] = mapped_column(primary_key=True) + month_id: Mapped[int] = mapped_column( + ForeignKey("month.id", ondelete="CASCADE"), nullable=False, index=True + ) + section: Mapped[Section] = mapped_column( + Enum(Section, native_enum=False, length=32), nullable=False, index=True + ) + 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", + ) + origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + origin_planned: Mapped[Decimal | None] = mapped_column( + Numeric(10, 2), nullable=True + ) + source_entry_id: Mapped[int | None] = mapped_column( + ForeignKey("entry.id", ondelete="SET NULL"), 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, + ) + + month: Mapped[Month] = relationship(back_populates="entries") + + +class MonthDebtTarget(Base): + __tablename__ = "month_debt_target" + + month_id: Mapped[int] = mapped_column( + ForeignKey("month.id", ondelete="CASCADE"), + primary_key=True, + autoincrement=False, + ) + month_entry_id: Mapped[int | None] = mapped_column( + ForeignKey("month_entry.id", ondelete="SET NULL"), nullable=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + month: Mapped[Month] = relationship(back_populates="target") + entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined") From ed038fd974b5aac6eaf7cb66a4c5bb2b8bb4db72 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:39:55 -0600 Subject: [PATCH 2/5] feat(month): add snapshot service with deviation tracking create_month copies every budget entry into month_entry with origin_name/origin_planned retained, resolves the budget's debt target through source_entry_id to the corresponding MonthEntry, and is idempotent. deviation_state classifies each row as unchanged, edited, or new_in_month. Year-month handling (validation, shift across year boundaries) lives here so the route layer stays thin. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/month_service.py | 228 +++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/quartermaster/month_service.py diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py new file mode 100644 index 0000000..7c96387 --- /dev/null +++ b/src/quartermaster/month_service.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from enum import Enum + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from quartermaster.models import ( + DebtTarget, + Entry, + Month, + MonthDebtTarget, + MonthEntry, + Section, +) + +YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") + + +class DeviationState(str, Enum): + unchanged = "unchanged" + edited = "edited" + new_in_month = "new_in_month" + + +@dataclass(frozen=True) +class MonthRow: + entry: MonthEntry + state: DeviationState + + +@dataclass(frozen=True) +class MonthSectionView: + section: Section + label: str + rows: list[MonthRow] + total_planned: Decimal + total_applied: Decimal + + +def valid_year_month(year_month: str) -> bool: + return bool(YEAR_MONTH_RE.match(year_month)) + + +def current_year_month() -> str: + today = date.today() + return f"{today.year:04d}-{today.month:02d}" + + +def shift_year_month(year_month: str, delta: int) -> str: + year, month = (int(part) for part in year_month.split("-")) + index = (year * 12 + (month - 1)) + delta + new_year, new_month0 = divmod(index, 12) + return f"{new_year:04d}-{new_month0 + 1:02d}" + + +def get_month(db: Session, year_month: str) -> Month | None: + stmt = select(Month).where(Month.year_month == year_month) + return db.scalar(stmt) + + +def list_months(db: Session) -> list[Month]: + stmt = select(Month).order_by(Month.year_month) + return list(db.scalars(stmt)) + + +def create_month(db: Session, year_month: str) -> Month: + if not valid_year_month(year_month): + raise ValueError("year_month must be formatted as YYYY-MM") + existing = get_month(db, year_month) + if existing is not None: + return existing + + month = Month(year_month=year_month) + db.add(month) + db.flush() + + budget_entries = list(db.scalars(select(Entry).order_by(Entry.id))) + source_to_month_entry: dict[int, MonthEntry] = {} + for e in budget_entries: + month_entry = MonthEntry( + month_id=month.id, + section=e.section, + name=e.name, + planned=e.amount, + applied=Decimal("0.00"), + origin_name=e.name, + origin_planned=e.amount, + source_entry_id=e.id, + ) + db.add(month_entry) + source_to_month_entry[e.id] = month_entry + + db.flush() + + budget_target = db.get(DebtTarget, 1) + target_entry_id: int | None = None + if budget_target is not None and budget_target.debt_minimum_id is not None: + mapped = source_to_month_entry.get(budget_target.debt_minimum_id) + if mapped is not None: + target_entry_id = mapped.id + + db.add( + MonthDebtTarget(month_id=month.id, month_entry_id=target_entry_id) + ) + db.commit() + db.refresh(month) + return month + + +def deviation_state(entry: MonthEntry) -> DeviationState: + if entry.origin_name is None or entry.origin_planned is None: + return DeviationState.new_in_month + if entry.name != entry.origin_name or entry.planned != entry.origin_planned: + return DeviationState.edited + return DeviationState.unchanged + + +def _rows(entries: list[MonthEntry]) -> list[MonthRow]: + return [MonthRow(entry=e, state=deviation_state(e)) for e in entries] + + +def section_view(month: Month, section: Section, label: str) -> MonthSectionView: + entries = [e for e in month.entries if e.section == section] + entries.sort(key=lambda e: e.id) + rows = _rows(entries) + total_planned = sum((e.planned for e in entries), Decimal("0")) + total_applied = sum((e.applied for e in entries), Decimal("0")) + return MonthSectionView( + section=section, + label=label, + rows=rows, + total_planned=total_planned, + total_applied=total_applied, + ) + + +def add_month_entry( + db: Session, + month: Month, + section: Section, + name: str, + planned: Decimal, +) -> MonthEntry: + entry = MonthEntry( + month_id=month.id, + section=section, + name=name.strip(), + planned=planned, + applied=Decimal("0.00"), + origin_name=None, + origin_planned=None, + source_entry_id=None, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def get_month_entry(db: Session, month: Month, entry_id: int) -> MonthEntry | None: + entry = db.get(MonthEntry, entry_id) + if entry is None or entry.month_id != month.id: + return None + return entry + + +def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | None: + entry = get_month_entry(db, month, entry_id) + if entry is None: + return None + section = entry.section + db.delete(entry) + db.commit() + return section + + +def update_month_entry( + db: Session, + month: Month, + entry_id: int, + *, + name: str | None = None, + planned: Decimal | None = None, + applied: Decimal | None = None, +) -> MonthEntry | None: + entry = get_month_entry(db, month, entry_id) + if entry is None: + return None + if name is not None: + entry.name = name.strip() + if planned is not None: + entry.planned = planned + if applied is not None: + entry.applied = applied + db.commit() + db.refresh(entry) + return entry + + +def get_month_target(db: Session, month: Month) -> MonthDebtTarget: + if month.target is not None: + return month.target + target = MonthDebtTarget(month_id=month.id, month_entry_id=None) + db.add(target) + db.commit() + db.refresh(target) + return target + + +def set_month_target( + db: Session, month: Month, month_entry_id: int | None +) -> MonthDebtTarget: + target = get_month_target(db, month) + if month_entry_id is not None: + candidate = get_month_entry(db, month, month_entry_id) + if candidate is None or candidate.section != Section.debt_minimum: + raise ValueError( + "month_entry_id must reference a debt minimum entry in this month" + ) + target.month_entry_id = month_entry_id + db.commit() + db.refresh(target) + return target From e7354ba8d62526e8c7046fc8b34b3ec3402543cd Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:40:02 -0600 Subject: [PATCH 3/5] feat(month): add routes, templates, and nav between budget and months Non-existent months return a page with a single "Create this month" button; create POSTs return HX-Redirect to the newly-created month. Each entry row carries three inline HTMX-wired inputs (name, planned, applied) that trigger on change, posting only the field that changed. Edits swap the section partial so totals and deviation tags update together. Deleting a debt minimum in a month also re-renders the target card via OOB swap. The budget page grows a This-month link and a month picker; each month page has prev / next / picker / back-to- config controls. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/main.py | 2 + src/quartermaster/routes.py | 5 +- src/quartermaster/routes_month.py | 224 ++++++++++++++++++ src/quartermaster/static/app.css | 121 ++++++++++ src/quartermaster/templates/index.html | 17 ++ src/quartermaster/templates/month.html | 14 ++ src/quartermaster/templates/month_create.html | 23 ++ .../templates/partials/month_nav.html | 22 ++ .../templates/partials/month_section.html | 91 +++++++ .../templates/partials/month_target.html | 46 ++++ 10 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 src/quartermaster/routes_month.py create mode 100644 src/quartermaster/templates/month.html create mode 100644 src/quartermaster/templates/month_create.html create mode 100644 src/quartermaster/templates/partials/month_nav.html create mode 100644 src/quartermaster/templates/partials/month_section.html create mode 100644 src/quartermaster/templates/partials/month_target.html diff --git a/src/quartermaster/main.py b/src/quartermaster/main.py index 3c1a2ad..30df371 100644 --- a/src/quartermaster/main.py +++ b/src/quartermaster/main.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from quartermaster.routes import router +from quartermaster.routes_month import router as month_router STATIC_DIR = Path(__file__).parent / "static" @@ -14,6 +15,7 @@ def create_app() -> FastAPI: app = FastAPI(title="Quartermaster", version="0.1.0") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(router) + app.include_router(month_router) return app diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index eb7c6c9..b208268 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session -from quartermaster import service +from quartermaster import month_service, service from quartermaster.db import get_session from quartermaster.models import SECTION_LABELS, Section @@ -62,6 +62,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: sections = [_section_view(db, s) for s in Section] target = service.get_debt_target(db) debt_minimums = service.list_entries(db, Section.debt_minimum) + current_ym = month_service.current_year_month() return templates.TemplateResponse( request, "index.html", @@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: "sections": sections, "target": target, "debt_minimums": debt_minimums, + "current_year_month": current_ym, + "all_months": month_service.list_months(db), }, ) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py new file mode 100644 index 0000000..9908829 --- /dev/null +++ b/src/quartermaster/routes_month.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, Response +from sqlalchemy.orm import Session + +from quartermaster import month_service, service +from quartermaster.db import get_session +from quartermaster.models import SECTION_LABELS, Month, Section +from quartermaster.routes import templates + +router = APIRouter(prefix="/month/{year_month}", tags=["month"]) + + +def _parse_amount(raw: str, *, allow_zero: bool = True) -> Decimal: + try: + amount = Decimal(raw.strip()) + except (InvalidOperation, AttributeError) as exc: + raise HTTPException(status_code=400, detail="amount must be numeric") from exc + if amount < 0: + raise HTTPException(status_code=400, detail="amount must be non-negative") + if amount == 0 and not allow_zero: + raise HTTPException(status_code=400, detail="amount must be positive") + return amount.quantize(Decimal("0.01")) + + +def _require_year_month(year_month: str) -> str: + if not month_service.valid_year_month(year_month): + raise HTTPException( + status_code=404, detail="year_month must be formatted as YYYY-MM" + ) + return year_month + + +def _require_month(db: Session, year_month: str) -> Month: + _require_year_month(year_month) + month = month_service.get_month(db, year_month) + if month is None: + raise HTTPException(status_code=404, detail="month has not been created yet") + return month + + +def _section_views(month: Month) -> list[month_service.MonthSectionView]: + return [ + month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section + ] + + +def _render_section( + request: Request, month: Month, section: Section +) -> HTMLResponse: + view = month_service.section_view(month, section, SECTION_LABELS[section]) + return templates.TemplateResponse( + request, + "partials/month_section.html", + {"month": month, "section": view}, + ) + + +def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse: + target = month_service.get_month_target(db, month) + debt_minimums = [e for e in month.entries if e.section == Section.debt_minimum] + debt_minimums.sort(key=lambda e: e.id) + return templates.TemplateResponse( + request, + "partials/month_target.html", + {"month": month, "target": target, "debt_minimums": debt_minimums}, + ) + + +@router.get("", response_class=HTMLResponse) +def view_month( + year_month: str, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + _require_year_month(year_month) + month = month_service.get_month(db, year_month) + prev_ym = month_service.shift_year_month(year_month, -1) + next_ym = month_service.shift_year_month(year_month, 1) + all_months = month_service.list_months(db) + if month is None: + return templates.TemplateResponse( + request, + "month_create.html", + { + "year_month": year_month, + "prev_year_month": prev_ym, + "next_year_month": next_ym, + "all_months": all_months, + }, + ) + return templates.TemplateResponse( + request, + "month.html", + { + "month": month, + "year_month": year_month, + "prev_year_month": prev_ym, + "next_year_month": next_ym, + "all_months": all_months, + "sections": _section_views(month), + "target": month_service.get_month_target(db, month), + "debt_minimums": sorted( + (e for e in month.entries if e.section == Section.debt_minimum), + key=lambda e: e.id, + ), + }, + ) + + +@router.post("/create") +def create_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + _require_year_month(year_month) + try: + month_service.create_month(db, year_month) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + +@router.post("/sections/{section}/entries", response_class=HTMLResponse) +def add_month_entry( + year_month: str, + section: Section, + request: Request, + name: str = Form(...), + planned: str = Form(...), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + clean_name = name.strip() + 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.refresh(month) + return _render_section(request, month, section) + + +@router.delete("/entries/{entry_id}", response_class=HTMLResponse) +def delete_month_entry( + year_month: str, + entry_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + section = month_service.delete_month_entry(db, month, entry_id) + if section is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + response = _render_section(request, month, section) + if section == Section.debt_minimum: + target_html = _render_target(request, db, month).body.decode() + response = HTMLResponse(response.body.decode() + target_html) + return response + + +@router.post("/entries/{entry_id}", response_class=HTMLResponse) +def update_month_entry( + year_month: str, + entry_id: int, + request: Request, + name: str | None = Form(None), + planned: str | None = Form(None), + applied: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + clean_name: str | None = None + if name is not None: + clean_name = name.strip() + 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 + updated = month_service.update_month_entry( + db, + month, + entry_id, + name=clean_name, + planned=parsed_planned, + applied=parsed_applied, + ) + if updated is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + return _render_section(request, month, updated.section) + + +@router.post("/target", response_class=HTMLResponse) +def update_month_target( + year_month: str, + request: Request, + month_entry_id: str = Form(""), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_month(db, year_month) + raw = month_entry_id.strip() + target_id: int | None + if raw == "": + target_id = None + else: + try: + target_id = int(raw) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="month_entry_id must be an integer" + ) from exc + try: + month_service.set_month_target(db, month, target_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return _render_target(request, db, month) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 13c3aa8..167b549 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -156,3 +156,124 @@ button[type=submit] { .target-section .section-header { border-bottom-style: dashed; } + +/* Monthly view ----------------------------------------------------------- */ + +.month-nav { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + margin-top: 1rem; + flex-wrap: wrap; +} + +.month-nav .month-label { + font-size: 1.2rem; + font-weight: 600; + padding: 0 0.5rem; +} + +.month-nav .nav-link { + color: var(--accent); + text-decoration: none; + padding: 0.25rem 0.5rem; + border: 1px solid var(--rule); + border-radius: 3px; + background: #fff; + font-size: 0.9rem; +} + +.month-nav .nav-link:hover { + background: #f0ece0; +} + +.month-nav .spacer { + flex: 1; +} + +.month-nav .month-picker { + padding: 0.3rem 0.5rem; + font-size: 0.9rem; +} + +.total .divider { + color: var(--muted); + margin: 0 0.25rem; + font-weight: 400; +} + +.total .planned { + color: var(--muted); +} + +table.month-entries thead th { + text-align: left; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + color: var(--muted); + font-weight: 500; + border-bottom: 1px solid var(--rule); +} + +table.month-entries th.col-planned, +table.month-entries th.col-applied { + text-align: right; + width: 8rem; +} + +table.month-entries th.col-actions { + width: 2.25rem; +} + +table.month-entries td.entry-name input[name="name"] { + width: 100%; + min-width: 8rem; +} + +table.month-entries td.entry-amount input { + width: 100%; + text-align: right; +} + +tr.entry.state-edited { + background: #fff7e0; +} + +tr.entry.state-new_in_month { + background: #e8f2ff; +} + +.tag { + display: inline-block; + margin-left: 0.4rem; + padding: 0.05rem 0.4rem; + border-radius: 3px; + font-size: 0.72rem; + font-weight: 600; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.tag-edited { + background: #f4d07a; + color: #5a3f00; +} + +.tag-new { + background: #9cc5ef; + color: #123a66; +} + +.month-add-form { + grid-template-columns: 1fr 9rem auto; +} + +.month-missing-body { + padding: 0.75rem 0.5rem; + color: var(--muted); +} + +.month-create-form { + padding: 0.25rem 0.5rem 0.75rem; +} diff --git a/src/quartermaster/templates/index.html b/src/quartermaster/templates/index.html index ec46dc5..4d48a18 100644 --- a/src/quartermaster/templates/index.html +++ b/src/quartermaster/templates/index.html @@ -1,6 +1,23 @@ {% extends "base.html" %} {% block content %}
+ {% for section in sections %} {% if section.section.value == 'debt_minimum' %} {% include "partials/section.html" %} diff --git a/src/quartermaster/templates/month.html b/src/quartermaster/templates/month.html new file mode 100644 index 0000000..91d2475 --- /dev/null +++ b/src/quartermaster/templates/month.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+ {% include "partials/month_nav.html" %} + {% for section in sections %} + {% if section.section.value == 'debt_minimum' %} + {% include "partials/month_section.html" %} + {% include "partials/month_target.html" %} + {% else %} + {% include "partials/month_section.html" %} + {% endif %} + {% endfor %} +
+{% endblock %} diff --git a/src/quartermaster/templates/month_create.html b/src/quartermaster/templates/month_create.html new file mode 100644 index 0000000..5262a8a --- /dev/null +++ b/src/quartermaster/templates/month_create.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+ {% include "partials/month_nav.html" %} +
+
+

No snapshot yet

+
+

+ This month has not been created. Creating it will snapshot the current + budget: every entry, its planned amount, and the current Primary Debt + Target. Applied amounts start at $0.00. +

+
+ +
+
+
+{% endblock %} diff --git a/src/quartermaster/templates/partials/month_nav.html b/src/quartermaster/templates/partials/month_nav.html new file mode 100644 index 0000000..3d3c636 --- /dev/null +++ b/src/quartermaster/templates/partials/month_nav.html @@ -0,0 +1,22 @@ + diff --git a/src/quartermaster/templates/partials/month_section.html b/src/quartermaster/templates/partials/month_section.html new file mode 100644 index 0000000..922316d --- /dev/null +++ b/src/quartermaster/templates/partials/month_section.html @@ -0,0 +1,91 @@ +
+
+

{{ section.label }}

+ + ${{ '%.2f' | format(section.total_applied) }} + / + ${{ '%.2f' | format(section.total_planned) }} + +
+ + + + + + + + + + + {% for row in section.rows %} + + + + + + + {% else %} + + {% endfor %} + + + + +
NamePlannedApplied
+ + {% if row.state.value == 'edited' %} + modified + {% elif row.state.value == 'new_in_month' %} + new this month + {% endif %} + + + + + + +
No entries.
+
+ + + +
+
+
diff --git a/src/quartermaster/templates/partials/month_target.html b/src/quartermaster/templates/partials/month_target.html new file mode 100644 index 0000000..5f0b73a --- /dev/null +++ b/src/quartermaster/templates/partials/month_target.html @@ -0,0 +1,46 @@ +
+
+

Primary Debt Target

+ {% if target.entry %} + + ${{ '%.2f' | format(target.entry.applied) }} + / + ${{ '%.2f' | format(target.entry.planned) }} + + {% else %} + $0.00 + {% endif %} +
+ + + + + + + + + + + +
+ {% if target.entry %}{{ target.entry.name }}{% else %}No target selected.{% endif %} +
+
+ + +
+
+
From abdb68a29cf48c4d8dad901399874a3eb55fae20 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:40:05 -0600 Subject: [PATCH 4/5] test: cover month snapshot, deviation states, and per-month target Service tests assert that create_month produces origin fields matching the budget, that edits flip deviation_state to edited, that added rows are new_in_month, and that a budget entry deleted after snapshot leaves the month entry unchanged. Route tests exercise the create flow, applied updates, name edits producing the modified tag, per-month target isolation, and the malformed-year-month 404. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_month_routes.py | 139 +++++++++++++++++++++++++++++++++ tests/test_month_service.py | 148 ++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/test_month_routes.py create mode 100644 tests/test_month_service.py diff --git a/tests/test_month_routes.py b/tests/test_month_routes.py new file mode 100644 index 0000000..a9b176d --- /dev/null +++ b/tests/test_month_routes.py @@ -0,0 +1,139 @@ +from __future__ import annotations + + +def _seed_budget_via_api(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "1200.00"}, + ) + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card A", "amount": "40.00"}, + ) + + +def test_missing_month_renders_create_flow(client): + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "No snapshot yet" in response.text + assert 'hx-post="/month/2026-04/create"' in response.text + + +def test_create_month_redirects_via_htmx(client): + _seed_budget_via_api(client) + response = client.post("/month/2026-04/create") + assert response.status_code == 204 + assert response.headers.get("hx-redirect") == "/month/2026-04" + + +def test_created_month_renders_snapshot(client): + _seed_budget_via_api(client) + client.post("/month/2026-04/create") + response = client.get("/month/2026-04") + assert response.status_code == 200 + 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 + + +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) + response = client.post( + "/month/2026-04/entries/2", data={"applied": "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 + + +def test_name_edit_flips_to_modified(client): + _seed_budget_via_api(client) + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/entries/2", data={"name": "Rent (April)"} + ) + assert response.status_code == 200 + assert "state-edited" in response.text + assert 'class="tag tag-edited">modified' in response.text + + +def test_add_new_entry_within_month_is_marked_new(client): + _seed_budget_via_api(client) + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/sections/other/entries", + data={"name": "Gift", "planned": "50.00"}, + ) + assert response.status_code == 200 + assert "state-new_in_month" in response.text + assert "new this month" in response.text + + +def test_delete_month_entry(client): + _seed_budget_via_api(client) + client.post("/month/2026-04/create") + response = client.delete("/month/2026-04/entries/2") + assert response.status_code == 200 + assert "Rent" not in response.text + + +def test_delete_month_debt_minimum_updates_target(client): + _seed_budget_via_api(client) + client.post("/month/2026-04/create") + # Card A is entry id 3 in the month snapshot + response = client.delete("/month/2026-04/entries/3") + assert response.status_code == 200 + assert "section-debt_target" in response.text + assert "No target selected" in response.text + + +def test_set_month_target(client): + _seed_budget_via_api(client) + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card B", "amount": "60.00"}, + ) + client.post("/month/2026-04/create") + # Card B is month_entry id 4 + response = client.post( + "/month/2026-04/target", data={"month_entry_id": "4"} + ) + assert response.status_code == 200 + assert "Card B" in response.text + + +def test_month_target_isolated_between_months(client): + _seed_budget_via_api(client) + client.post( + "/sections/debt_minimum/entries", + data={"name": "Card B", "amount": "60.00"}, + ) + client.post("/month/2026-04/create") + client.post("/month/2026-05/create") + # Change April target to Card B (month_entry id 4 within April) + client.post("/month/2026-04/target", data={"month_entry_id": "4"}) + may_page = client.get("/month/2026-05") + # May still points at Card A (copied from budget) + assert "Card A" in may_page.text + + +def test_malformed_year_month_returns_404(client): + response = client.get("/month/2026-13") + assert response.status_code == 404 + + +def test_budget_page_shows_month_nav(client): + response = client.get("/") + assert response.status_code == 200 + assert "This month" in response.text diff --git a/tests/test_month_service.py b/tests/test_month_service.py new file mode 100644 index 0000000..11a69e0 --- /dev/null +++ b/tests/test_month_service.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from quartermaster import month_service, service +from quartermaster.models import Section + + +def _seed_budget(db): + income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) + rent = service.add_entry( + db, Section.fixed_bill, "Rent", Decimal("1200.00") + ) + card_a = service.add_entry( + db, Section.debt_minimum, "Card A", Decimal("40.00") + ) + card_b = service.add_entry( + db, Section.debt_minimum, "Card B", Decimal("60.00") + ) + service.set_debt_target(db, card_a.id) + return income, rent, card_a, card_b + + +def test_valid_year_month(): + assert month_service.valid_year_month("2026-04") + assert month_service.valid_year_month("2026-12") + assert not month_service.valid_year_month("2026-00") + assert not month_service.valid_year_month("2026-13") + assert not month_service.valid_year_month("26-04") + assert not month_service.valid_year_month("2026/04") + + +def test_shift_year_month_rolls_years(): + assert month_service.shift_year_month("2026-12", 1) == "2027-01" + assert month_service.shift_year_month("2026-01", -1) == "2025-12" + assert month_service.shift_year_month("2026-04", 0) == "2026-04" + assert month_service.shift_year_month("2026-04", 12) == "2027-04" + + +def test_create_month_snapshots_budget(db): + income, rent, card_a, card_b = _seed_budget(db) + month = month_service.create_month(db, "2026-04") + assert month.year_month == "2026-04" + assert len(month.entries) == 4 + by_source = {e.source_entry_id: e for e in month.entries} + assert by_source[income.id].planned == Decimal("2500.00") + assert by_source[income.id].origin_name == "Paycheck" + assert by_source[income.id].origin_planned == Decimal("2500.00") + assert by_source[income.id].applied == Decimal("0.00") + assert by_source[rent.id].section == Section.fixed_bill + assert month.target is not None + assert month.target.entry is not None + assert month.target.entry.source_entry_id == card_a.id + + +def test_create_month_idempotent(db): + _seed_budget(db) + first = month_service.create_month(db, "2026-04") + second = month_service.create_month(db, "2026-04") + assert first.id == second.id + + +def test_create_month_rejects_bad_year_month(db): + with pytest.raises(ValueError): + month_service.create_month(db, "2026-00") + + +def test_deviation_state_reflects_edits(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") + assert month_service.deviation_state(rent) == month_service.DeviationState.unchanged + + month_service.update_month_entry( + db, month, rent.id, planned=Decimal("1300.00") + ) + rent = next(e for e in month.entries if e.id == rent.id) + assert month_service.deviation_state(rent) == month_service.DeviationState.edited + + +def test_added_row_is_new_in_month(db): + _seed_budget(db) + month = month_service.create_month(db, "2026-04") + extra = month_service.add_month_entry( + db, month, Section.other, "Surprise", Decimal("20.00") + ) + assert extra.origin_name is None + assert month_service.deviation_state(extra) == ( + month_service.DeviationState.new_in_month + ) + + +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") + ) + view = month_service.section_view(month, Section.fixed_bill, "Fixed") + assert view.total_applied == Decimal("1200.00") + assert view.total_planned == Decimal("1200.00") + + +def test_per_month_target_is_isolated(db): + _seed_budget(db) + april = month_service.create_month(db, "2026-04") + may = month_service.create_month(db, "2026-05") + april_b = next( + e for e in april.entries + if e.section == Section.debt_minimum and e.origin_name == "Card B" + ) + month_service.set_month_target(db, april, april_b.id) + april_target = month_service.get_month_target(db, april) + may_target = month_service.get_month_target(db, may) + assert april_target.entry is not None + assert april_target.entry.origin_name == "Card B" + assert may_target.entry is not None + assert may_target.entry.origin_name == "Card A" + + +def test_delete_month_entry_returns_section(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") + result = month_service.delete_month_entry(db, month, rent.id) + assert result == Section.fixed_bill + db.refresh(month) + remaining = [ + e for e in month.entries if e.section == Section.fixed_bill + ] + assert remaining == [] + + +def test_budget_entry_deleted_after_snapshot_leaves_month_intact(db): + _seed_budget(db) + month = month_service.create_month(db, "2026-04") + # remove budget-side rent; month-side rent should still render unchanged + budget_rent = service.list_entries(db, Section.fixed_bill)[0] + service.delete_entry(db, budget_rent.id) + db.expire_all() + month_rent = next(e for e in month.entries if e.origin_name == "Rent") + assert month_rent.source_entry_id is None + assert month_service.deviation_state(month_rent) == ( + month_service.DeviationState.unchanged + ) From b2d16120d28777ec91c05471fc957854ebbaf4ae Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:40:06 -0600 Subject: [PATCH 5/5] docs: document monthly view, updated layout, and deferred work Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 54 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 62d9582..9cdec68 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,22 @@ Household budget tracker. FastAPI + HTMX frontend, SQLite backend. -## Sections +## Pages -The budget page shows one card per section. Every section accepts a name and -amount per entry and displays a running total, except *Primary Debt Target*, -which is a pointer to one of the *Debt Minimums* rows. +* `/` budget configuration. One section per category (Incomes, Fixed Amount + Bills, Debt Minimums, Primary Debt Target, Food and Essentials, + Subscriptions, Other). Every section accepts name + amount entries and + shows a running total. The Primary Debt Target is a pointer to a Debt + Minimums row. +* `/month/YYYY-MM` monthly view. Snapshots the budget at creation time and + tracks an `applied` amount per entry alongside the planned amount. Each + row is annotated when its name or planned value has been edited away + from the snapshot, or when the row was added after creation. Per-month + debt target is independent of the budget's target after snapshot. -* Incomes -* Fixed Amount Bills -* Debt Minimums -* Primary Debt Target (pointer) -* Food and Essentials -* Subscriptions -* Other +Navigate between months with the prev / next buttons or the dropdown +picker. A "This month" link on `/` jumps to the current `YYYY-MM`; if it +has not been created yet, you land on the create flow. ## Requirements @@ -68,19 +71,22 @@ over `quartermaster.db`, and restart. ``` src/quartermaster/ - main.py FastAPI app factory - routes.py HTTP handlers, HTMX partials - service.py Queries, totals, target logic - models.py SQLAlchemy models and Section enum - db.py Engine, session, PRAGMA foreign_keys=ON - config.py DB URL resolution - templates/ Jinja2 templates (base, index, partials) - static/ CSS -alembic/ Migrations -tests/ pytest suite + main.py FastAPI app factory + routes.py Budget configuration HTTP handlers + routes_month.py Monthly view HTTP handlers + service.py Budget queries, totals, target logic + month_service.py Snapshot, deviation, per-month CRUD + models.py SQLAlchemy models and Section enum + db.py Engine, session, PRAGMA foreign_keys=ON + config.py DB URL resolution + templates/ Jinja2 templates (base, index, month, partials) + static/ CSS +alembic/ Migrations +tests/ pytest suite ``` -## Scope +## Deferred -Single-month budget. Multi-month support is planned for a later milestone -and is intentionally not modelled yet. +A transaction log that rolls up into `applied` on a per-entry per-month +basis is deferred. Once implemented it may replace the hand-edited applied +field.