Monthly budget view with snapshot and applied tracking (#4)

This commit is contained in:
claude-code 2026-04-17 11:59:08 -06:00
commit 38c8921885
16 changed files with 1262 additions and 25 deletions

View file

@ -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
@ -69,18 +72,21 @@ over `quartermaster.db`, and restart.
```
src/quartermaster/
main.py FastAPI app factory
routes.py HTTP handlers, HTMX partials
service.py Queries, totals, target logic
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, partials)
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.

View file

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

View file

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

View file

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

View file

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

View file

@ -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),
},
)

View file

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

View file

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

View file

@ -1,6 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="budget">
<nav class="month-nav budget-nav">
<span class="month-label">Budget configuration</span>
<span class="spacer"></span>
<a class="nav-link" href="/month/{{ current_year_month }}">This month ({{ current_year_month }})</a>
{% if all_months %}
<select
class="month-picker"
onchange="if(this.value){window.location=this.value}"
aria-label="Jump to month"
>
<option value="">Jump to...</option>
{% for m in all_months %}
<option value="/month/{{ m.year_month }}">{{ m.year_month }}</option>
{% endfor %}
</select>
{% endif %}
</nav>
{% for section in sections %}
{% if section.section.value == 'debt_minimum' %}
{% include "partials/section.html" %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="budget">
{% 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 %}
</div>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="budget">
{% include "partials/month_nav.html" %}
<section class="section month-missing">
<div class="section-header">
<h2>No snapshot yet</h2>
</div>
<p class="month-missing-body">
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.
</p>
<form
hx-post="/month/{{ year_month }}/create"
hx-swap="none"
class="month-create-form"
>
<button type="submit">Create {{ year_month }}</button>
</form>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
<nav class="month-nav">
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">&larr; {{ prev_year_month }}</a>
<span class="month-label">{{ year_month }}</span>
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} &rarr;</a>
<span class="spacer"></span>
{% if all_months %}
<select
class="month-picker"
onchange="if(this.value){window.location=this.value}"
aria-label="Jump to month"
>
<option value="">Jump to...</option>
{% for m in all_months %}
<option
value="/month/{{ m.year_month }}"
{% if m.year_month == year_month %}selected{% endif %}
>{{ m.year_month }}</option>
{% endfor %}
</select>
{% endif %}
<a class="nav-link" href="/">Budget config</a>
</nav>

View file

@ -0,0 +1,91 @@
<section class="section" id="section-{{ section.section.value }}">
<div class="section-header">
<h2>{{ section.label }}</h2>
<span class="total" data-testid="total-{{ section.section.value }}">
<span class="applied">${{ '%.2f' | format(section.total_applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(section.total_planned) }}</span>
</span>
</div>
<table class="entries month-entries">
<thead>
<tr>
<th class="col-name">Name</th>
<th class="col-planned">Planned</th>
<th class="col-applied">Applied</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{% for row in section.rows %}
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
<td class="entry-name">
<input
type="text"
name="name"
value="{{ row.entry.name }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
>
{% if row.state.value == 'edited' %}
<span class="tag tag-edited">modified</span>
{% elif row.state.value == 'new_in_month' %}
<span class="tag tag-new">new this month</span>
{% endif %}
</td>
<td class="entry-amount">
<input
type="number" step="0.01" min="0"
name="planned"
value="{{ '%.2f' | format(row.entry.planned) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
>
</td>
<td class="entry-amount">
<input
type="number" step="0.01" min="0"
name="applied"
value="{{ '%.2f' | format(row.entry.applied) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
>
</td>
<td class="entry-actions">
<button
class="delete"
type="button"
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Delete {{ row.entry.name }}"
>&times;</button>
</td>
</tr>
{% else %}
<tr class="empty"><td colspan="4">No entries.</td></tr>
{% endfor %}
<tr class="add-row">
<td colspan="4">
<form
class="add-form month-add-form"
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
<input type="text" name="name" placeholder="Name" required>
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
<button type="submit">Add</button>
</form>
</td>
</tr>
</tbody>
</table>
</section>

View file

@ -0,0 +1,46 @@
<section class="section target-section" id="section-debt_target" hx-swap-oob="outerHTML">
<div class="section-header">
<h2>Primary Debt Target</h2>
{% if target.entry %}
<span class="total">
<span class="applied">${{ '%.2f' | format(target.entry.applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(target.entry.planned) }}</span>
</span>
{% else %}
<span class="total empty">$0.00</span>
{% endif %}
</div>
<table class="entries">
<tbody>
<tr class="entry">
<td class="entry-name">
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
</td>
<td class="entry-amount"></td>
<td class="entry-actions"></td>
</tr>
<tr class="add-row">
<td colspan="3">
<form
class="target-form"
hx-post="/month/{{ month.year_month }}/target"
hx-target="#section-debt_target"
hx-swap="outerHTML"
>
<select name="month_entry_id">
<option value="">(none)</option>
{% for dm in debt_minimums %}
<option
value="{{ dm.id }}"
{% if target.month_entry_id == dm.id %}selected{% endif %}
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
</td>
</tr>
</tbody>
</table>
</section>

139
tests/test_month_routes.py Normal file
View file

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

148
tests/test_month_service.py Normal file
View file

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