feat(web): add FastAPI routes, service layer, and HTMX templates

Index view renders one card per section plus the Primary Debt
Target card. Adding or deleting a Debt Minimums entry returns the
section partial plus an out-of-band swap for the target card so
the target dropdown stays in sync without a reload.

Refs #1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 11:04:13 -06:00
parent 0f5980bd94
commit c52fa9c470
8 changed files with 474 additions and 0 deletions

20
src/quartermaster/main.py Normal file
View file

@ -0,0 +1,20 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from quartermaster.routes import router
STATIC_DIR = Path(__file__).parent / "static"
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)
return app
app = create_app()

133
src/quartermaster/routes.py Normal file
View file

@ -0,0 +1,133 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from quartermaster import service
from quartermaster.db import get_session
from quartermaster.models import SECTION_LABELS, Section
TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
router = APIRouter()
def _parse_amount(raw: str) -> Decimal:
try:
amount = Decimal(raw.strip())
except (InvalidOperation, AttributeError) as exc:
raise HTTPException(status_code=400, detail="amount must be numeric") from exc
if amount < 0:
raise HTTPException(status_code=400, detail="amount must be non-negative")
return amount.quantize(Decimal("0.01"))
def _section_view(db: Session, section: Section) -> service.SectionView:
entries = service.list_entries(db, section)
return service.SectionView(
section=section,
label=SECTION_LABELS[section],
entries=entries,
total=service.section_total(entries),
)
def _render_section(
request: Request, db: Session, section: Section
) -> HTMLResponse:
view = _section_view(db, section)
return templates.TemplateResponse(
request, "partials/section.html", {"section": view}
)
def _render_target(request: Request, db: Session) -> HTMLResponse:
target = service.get_debt_target(db)
debt_minimums = service.list_entries(db, Section.debt_minimum)
return templates.TemplateResponse(
request,
"partials/target_card.html",
{"target": target, "debt_minimums": debt_minimums},
)
@router.get("/", response_class=HTMLResponse)
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)
return templates.TemplateResponse(
request,
"index.html",
{
"sections": sections,
"target": target,
"debt_minimums": debt_minimums,
},
)
@router.post("/sections/{section}/entries", response_class=HTMLResponse)
def create_entry(
section: Section,
request: Request,
name: str = Form(...),
amount: str = Form(...),
db: Session = Depends(get_session),
) -> HTMLResponse:
clean_name = name.strip()
if not clean_name:
raise HTTPException(status_code=400, detail="name is required")
parsed = _parse_amount(amount)
service.add_entry(db, section, clean_name, parsed)
response = _render_section(request, db, section)
if section == Section.debt_minimum:
target_html = _render_target(request, db).body.decode()
response = HTMLResponse(response.body.decode() + target_html)
return response
@router.delete("/entries/{entry_id}", response_class=HTMLResponse)
def remove_entry(
entry_id: int,
request: Request,
db: Session = Depends(get_session),
) -> HTMLResponse:
entry = service.delete_entry(db, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="entry not found")
response = _render_section(request, db, entry.section)
if entry.section == Section.debt_minimum:
target_html = _render_target(request, db).body.decode()
response = HTMLResponse(response.body.decode() + target_html)
return response
@router.post("/debt-target", response_class=HTMLResponse)
def update_debt_target(
request: Request,
debt_minimum_id: str = Form(""),
db: Session = Depends(get_session),
) -> HTMLResponse:
raw = debt_minimum_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="debt_minimum_id must be an integer"
) from exc
try:
service.set_debt_target(db, target_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _render_target(request, db)

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from quartermaster.models import DebtTarget, Entry, Section
@dataclass(frozen=True)
class SectionView:
section: Section
label: str
entries: list[Entry]
total: Decimal
def list_entries(db: Session, section: Section) -> list[Entry]:
stmt = select(Entry).where(Entry.section == section).order_by(Entry.id)
return list(db.scalars(stmt))
def section_total(entries: list[Entry]) -> Decimal:
return sum((e.amount for e in entries), Decimal("0"))
def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry:
entry = Entry(section=section, name=name.strip(), amount=amount)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def delete_entry(db: Session, entry_id: int) -> Entry | None:
entry = db.get(Entry, entry_id)
if entry is None:
return None
db.delete(entry)
db.commit()
return entry
def get_debt_target(db: Session) -> DebtTarget:
target = db.get(DebtTarget, 1)
if target is None:
target = DebtTarget(id=1, debt_minimum_id=None)
db.add(target)
db.commit()
db.refresh(target)
return target
def set_debt_target(db: Session, debt_minimum_id: int | None) -> DebtTarget:
target = get_debt_target(db)
if debt_minimum_id is not None:
candidate = db.get(Entry, debt_minimum_id)
if candidate is None or candidate.section != Section.debt_minimum:
raise ValueError("debt_minimum_id must reference a debt minimum entry")
target.debt_minimum_id = debt_minimum_id
db.commit()
db.refresh(target)
return target

View file

@ -0,0 +1,160 @@
:root {
--bg: #f7f6f2;
--card: #ffffff;
--ink: #1f1f1f;
--muted: #6b6b6b;
--accent: #2f6b4f;
--danger: #a03030;
--border: #d8d6cf;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
line-height: 1.4;
}
header {
padding: 1.5rem 2rem 0.5rem;
}
header h1 {
margin: 0;
font-size: 1.6rem;
}
.subtitle {
margin: 0;
color: var(--muted);
}
main {
padding: 1rem 2rem 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.card-header h2 {
margin: 0;
font-size: 1.1rem;
}
.total {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--accent);
}
ul.entries {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.entry {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 4px;
background: #fafaf7;
}
.entry-amount {
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.empty {
color: var(--muted);
font-style: italic;
}
button.delete {
background: transparent;
border: none;
color: var(--danger);
font-size: 1rem;
cursor: pointer;
padding: 0 0.35rem;
}
.add-form,
.target-form {
display: grid;
grid-template-columns: 1fr 7rem auto;
gap: 0.5rem;
}
.target-form {
grid-template-columns: 1fr auto;
align-items: center;
}
.target-form label {
grid-column: 1 / -1;
font-size: 0.85rem;
color: var(--muted);
}
.target-current {
margin: 0;
padding: 0.5rem 0.75rem;
background: #f0ece0;
border-radius: 4px;
display: flex;
justify-content: space-between;
}
.target-current.empty {
color: var(--muted);
font-style: italic;
}
input[type=text],
input[type=number],
select {
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
background: #fff;
}
button[type=submit] {
padding: 0.35rem 0.75rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quartermaster</title>
<link rel="stylesheet" href="{{ url_for('static', path='app.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.3" defer></script>
</head>
<body>
<header>
<h1>Quartermaster</h1>
<p class="subtitle">Household budget</p>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="grid">
{% for section in sections %}
{% include "partials/section.html" %}
{% endfor %}
{% include "partials/target_card.html" %}
</div>
{% endblock %}

View file

@ -0,0 +1,37 @@
<section class="card" id="section-{{ section.section.value }}">
<header class="card-header">
<h2>{{ section.label }}</h2>
<span class="total" data-testid="total-{{ section.section.value }}">
${{ '%.2f' | format(section.total) }}
</span>
</header>
<ul class="entries">
{% for entry in section.entries %}
<li class="entry">
<span class="entry-name">{{ entry.name }}</span>
<span class="entry-amount">${{ '%.2f' | format(entry.amount) }}</span>
<button
class="delete"
type="button"
hx-delete="/entries/{{ entry.id }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Delete {{ entry.name }}"
>&times;</button>
</li>
{% else %}
<li class="empty">No entries yet.</li>
{% endfor %}
</ul>
<form
class="add-form"
hx-post="/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="amount" step="0.01" min="0" placeholder="0.00" required>
<button type="submit">Add</button>
</form>
</section>

View file

@ -0,0 +1,31 @@
<section class="card target-card" id="section-debt_target" hx-swap-oob="outerHTML">
<header class="card-header">
<h2>Primary Debt Target</h2>
</header>
{% if target.entry %}
<p class="target-current">
<span class="entry-name">{{ target.entry.name }}</span>
<span class="entry-amount">${{ '%.2f' | format(target.entry.amount) }}</span>
</p>
{% else %}
<p class="target-current empty">No target selected.</p>
{% endif %}
<form
class="target-form"
hx-post="/debt-target"
hx-target="#section-debt_target"
hx-swap="outerHTML"
>
<label for="debt-target-select">Choose from Debt Minimums</label>
<select id="debt-target-select" name="debt_minimum_id">
<option value="">(none)</option>
{% for dm in debt_minimums %}
<option
value="{{ dm.id }}"
{% if target.debt_minimum_id == dm.id %}selected{% endif %}
>{{ dm.name }}: ${{ '%.2f' | format(dm.amount) }}</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
</section>