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/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 %}
{% include "partials/month_nav.html" %} + {% include "partials/month_zero.html" %} {% for section in sections %} {% if section.section.value == 'debt_minimum' %} {% include "partials/month_section.html" %} diff --git a/src/quartermaster/templates/partials/budget_zero.html b/src/quartermaster/templates/partials/budget_zero.html new file mode 100644 index 0000000..d7958a4 --- /dev/null +++ b/src/quartermaster/templates/partials/budget_zero.html @@ -0,0 +1,4 @@ +
+
Zero Amount
+
${{ '%.2f' | format(zero) }}
+
diff --git a/src/quartermaster/templates/partials/month_zero.html b/src/quartermaster/templates/partials/month_zero.html new file mode 100644 index 0000000..f991c82 --- /dev/null +++ b/src/quartermaster/templates/partials/month_zero.html @@ -0,0 +1,13 @@ +
+
Zero Amount
+
+
+
Planned
+
${{ '%.2f' | format(zero.planned) }}
+
+
+
Applied
+
${{ '%.2f' | format(zero.applied) }}
+
+
+