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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 11:40:02 -06:00
parent c6d4d65fe6
commit 647bf257f4
10 changed files with 564 additions and 1 deletions

View file

@ -6,6 +6,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from quartermaster.routes import router from quartermaster.routes import router
from quartermaster.routes_month import router as month_router
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
@ -14,6 +15,7 @@ def create_app() -> FastAPI:
app = FastAPI(title="Quartermaster", version="0.1.0") app = FastAPI(title="Quartermaster", version="0.1.0")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(router) app.include_router(router)
app.include_router(month_router)
return app return app

View file

@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from quartermaster import service from quartermaster import month_service, service
from quartermaster.db import get_session from quartermaster.db import get_session
from quartermaster.models import SECTION_LABELS, Section 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] sections = [_section_view(db, s) for s in Section]
target = service.get_debt_target(db) target = service.get_debt_target(db)
debt_minimums = service.list_entries(db, Section.debt_minimum) debt_minimums = service.list_entries(db, Section.debt_minimum)
current_ym = month_service.current_year_month()
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"index.html", "index.html",
@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
"sections": sections, "sections": sections,
"target": target, "target": target,
"debt_minimums": debt_minimums, "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 { .target-section .section-header {
border-bottom-style: dashed; 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" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="budget"> <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 %} {% for section in sections %}
{% if section.section.value == 'debt_minimum' %} {% if section.section.value == 'debt_minimum' %}
{% include "partials/section.html" %} {% 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>