Initial scaffold: single-month budget MVP #2
8 changed files with 474 additions and 0 deletions
20
src/quartermaster/main.py
Normal file
20
src/quartermaster/main.py
Normal 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
133
src/quartermaster/routes.py
Normal 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)
|
||||
65
src/quartermaster/service.py
Normal file
65
src/quartermaster/service.py
Normal 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
|
||||
160
src/quartermaster/static/app.css
Normal file
160
src/quartermaster/static/app.css
Normal 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;
|
||||
}
|
||||
19
src/quartermaster/templates/base.html
Normal file
19
src/quartermaster/templates/base.html
Normal 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>
|
||||
9
src/quartermaster/templates/index.html
Normal file
9
src/quartermaster/templates/index.html
Normal 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 %}
|
||||
37
src/quartermaster/templates/partials/section.html
Normal file
37
src/quartermaster/templates/partials/section.html
Normal 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 }}"
|
||||
>×</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>
|
||||
31
src/quartermaster/templates/partials/target_card.html
Normal file
31
src/quartermaster/templates/partials/target_card.html
Normal 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>
|
||||
Loading…
Reference in a new issue