diff --git a/src/quartermaster/main.py b/src/quartermaster/main.py new file mode 100644 index 0000000..3c1a2ad --- /dev/null +++ b/src/quartermaster/main.py @@ -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() diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py new file mode 100644 index 0000000..eb7c6c9 --- /dev/null +++ b/src/quartermaster/routes.py @@ -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) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py new file mode 100644 index 0000000..e5fb3ca --- /dev/null +++ b/src/quartermaster/service.py @@ -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 diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css new file mode 100644 index 0000000..98d8ba1 --- /dev/null +++ b/src/quartermaster/static/app.css @@ -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; +} diff --git a/src/quartermaster/templates/base.html b/src/quartermaster/templates/base.html new file mode 100644 index 0000000..807588b --- /dev/null +++ b/src/quartermaster/templates/base.html @@ -0,0 +1,19 @@ + + +
+ + +Household budget
++ {{ target.entry.name }} + ${{ '%.2f' | format(target.entry.amount) }} +
+ {% else %} +No target selected.
+ {% endif %} + +