diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 7c96387..c55e5bd 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -42,6 +42,12 @@ class MonthSectionView: total_applied: Decimal +@dataclass(frozen=True) +class ZeroAmounts: + planned: Decimal + applied: Decimal + + def valid_year_month(year_month: str) -> bool: return bool(YEAR_MONTH_RE.match(year_month)) @@ -124,6 +130,24 @@ def _rows(entries: list[MonthEntry]) -> list[MonthRow]: return [MonthRow(entry=e, state=deviation_state(e)) for e in entries] +def month_zero(month: Month) -> ZeroAmounts: + income_planned = Decimal("0") + income_applied = Decimal("0") + other_planned = Decimal("0") + other_applied = Decimal("0") + for entry in month.entries: + if entry.section == Section.income: + income_planned += entry.planned + income_applied += entry.applied + else: + other_planned += entry.planned + other_applied += entry.applied + return ZeroAmounts( + planned=(income_planned - other_planned).quantize(Decimal("0.01")), + applied=(income_applied - other_applied).quantize(Decimal("0.01")), + ) + + def section_view(month: Month, section: Section, label: str) -> MonthSectionView: entries = [e for e in month.entries if e.section == section] entries.sort(key=lambda e: e.id) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index b208268..dc65812 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -57,12 +57,27 @@ def _render_target(request: Request, db: Session) -> HTMLResponse: ) +def _render_zero(request: Request, db: Session) -> HTMLResponse: + zero = service.budget_zero(db) + return templates.TemplateResponse( + request, + "partials/budget_zero.html", + {"zero": zero, "tone": service.zero_tone(zero)}, + ) + + +def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: + body = base.body.decode() + "".join(e.body.decode() for e in extras) + return HTMLResponse(body) + + @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) current_ym = month_service.current_year_month() + zero = service.budget_zero(db) return templates.TemplateResponse( request, "index.html", @@ -72,6 +87,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: "debt_minimums": debt_minimums, "current_year_month": current_ym, "all_months": month_service.list_months(db), + "zero": zero, + "tone": service.zero_tone(zero), }, ) @@ -90,10 +107,10 @@ def create_entry( parsed = _parse_amount(amount) service.add_entry(db, section, clean_name, parsed) response = _render_section(request, db, section) + extras: list[HTMLResponse] = [_render_zero(request, db)] if section == Section.debt_minimum: - target_html = _render_target(request, db).body.decode() - response = HTMLResponse(response.body.decode() + target_html) - return response + extras.append(_render_target(request, db)) + return _append_oob(response, *extras) @router.delete("/entries/{entry_id}", response_class=HTMLResponse) @@ -106,10 +123,10 @@ def remove_entry( if entry is None: raise HTTPException(status_code=404, detail="entry not found") response = _render_section(request, db, entry.section) + extras: list[HTMLResponse] = [_render_zero(request, db)] if entry.section == Section.debt_minimum: - target_html = _render_target(request, db).body.decode() - response = HTMLResponse(response.body.decode() + target_html) - return response + extras.append(_render_target(request, db)) + return _append_oob(response, *extras) @router.post("/debt-target", response_class=HTMLResponse) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index 9908829..397501a 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -70,6 +70,24 @@ def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse: ) +def _render_zero(request: Request, month: Month) -> HTMLResponse: + zero = month_service.month_zero(month) + return templates.TemplateResponse( + request, + "partials/month_zero.html", + { + "zero": zero, + "planned_tone": service.zero_tone(zero.planned), + "applied_tone": service.zero_tone(zero.applied), + }, + ) + + +def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: + body = base.body.decode() + "".join(e.body.decode() for e in extras) + return HTMLResponse(body) + + @router.get("", response_class=HTMLResponse) def view_month( year_month: str, @@ -92,6 +110,7 @@ def view_month( "all_months": all_months, }, ) + zero = month_service.month_zero(month) return templates.TemplateResponse( request, "month.html", @@ -107,6 +126,9 @@ def view_month( (e for e in month.entries if e.section == Section.debt_minimum), key=lambda e: e.id, ), + "zero": zero, + "planned_tone": service.zero_tone(zero.planned), + "applied_tone": service.zero_tone(zero.applied), }, ) @@ -144,7 +166,10 @@ def add_month_entry( db, month, section, clean_name, _parse_amount(planned) ) db.refresh(month) - return _render_section(request, month, section) + return _append_oob( + _render_section(request, month, section), + _render_zero(request, month), + ) @router.delete("/entries/{entry_id}", response_class=HTMLResponse) @@ -159,11 +184,10 @@ def delete_month_entry( if section is None: raise HTTPException(status_code=404, detail="entry not found") db.refresh(month) - response = _render_section(request, month, section) + extras: list[HTMLResponse] = [_render_zero(request, month)] if section == Section.debt_minimum: - target_html = _render_target(request, db, month).body.decode() - response = HTMLResponse(response.body.decode() + target_html) - return response + extras.append(_render_target(request, db, month)) + return _append_oob(_render_section(request, month, section), *extras) @router.post("/entries/{entry_id}", response_class=HTMLResponse) @@ -195,7 +219,10 @@ def update_month_entry( if updated is None: raise HTTPException(status_code=404, detail="entry not found") db.refresh(month) - return _render_section(request, month, updated.section) + return _append_oob( + _render_section(request, month, updated.section), + _render_zero(request, month), + ) @router.post("/target", response_class=HTMLResponse) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index e5fb3ca..e6a97cc 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -9,6 +9,12 @@ from sqlalchemy.orm import Session from quartermaster.models import DebtTarget, Entry, Section +def zero_tone(value: Decimal) -> str: + if value == 0: + return "zero" + return "positive" if value > 0 else "negative" + + @dataclass(frozen=True) class SectionView: section: Section @@ -26,6 +32,18 @@ def section_total(entries: list[Entry]) -> Decimal: return sum((e.amount for e in entries), Decimal("0")) +def budget_zero(db: Session) -> Decimal: + stmt = select(Entry) + total_income = Decimal("0") + total_non_income = Decimal("0") + for entry in db.scalars(stmt): + if entry.section == Section.income: + total_income += entry.amount + else: + total_non_income += entry.amount + return (total_income - total_non_income).quantize(Decimal("0.01")) + + def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry: entry = Entry(section=section, name=name.strip(), amount=amount) db.add(entry) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 167b549..216aa9a 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -157,6 +157,59 @@ button[type=submit] { border-bottom-style: dashed; } +/* Zero Amount widget ----------------------------------------------------- */ + +:root { + --tone-zero: #2f6b4f; + --tone-positive: #b77b00; + --tone-negative: #a03030; +} + +.zero-widget { + margin-top: 1rem; + padding: 1rem 1.25rem; + background: #fff; + border: 1px solid var(--rule); + border-radius: 6px; + text-align: center; +} + +.zero-widget .zero-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--muted); + margin-bottom: 0.25rem; +} + +.zero-widget .zero-value { + font-size: 2.4rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.zero-widget .zero-sublabel { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.zero-widget-pair .zero-values { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.zero-widget-pair .zero-value { + font-size: 2rem; +} + +.tone-zero { color: var(--tone-zero); } +.tone-positive { color: var(--tone-positive); } +.tone-negative { color: var(--tone-negative); } + /* Monthly view ----------------------------------------------------------- */ .month-nav { diff --git a/src/quartermaster/templates/index.html b/src/quartermaster/templates/index.html index 4d48a18..71719f3 100644 --- a/src/quartermaster/templates/index.html +++ b/src/quartermaster/templates/index.html @@ -18,6 +18,7 @@ {% endif %} + {% include "partials/budget_zero.html" %} {% 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 index 91d2475..3f8d31e 100644 --- a/src/quartermaster/templates/month.html +++ b/src/quartermaster/templates/month.html @@ -2,6 +2,7 @@ {% block content %}