From 647bf257f4a9936879eb5ddc4b54cf71427ed5ae Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:40:02 -0600 Subject: [PATCH] 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 %} +
+
+ + +
+
+