From 4d9b64d760e9420c05dbb5828ff5e2260edc3fdd Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:18 -0600 Subject: [PATCH] feat(groups): render collapsible group details and OOB-swap subtotals Budget and month pages now wrap sections in native
blocks with summary rows showing the group name and subtotal. Income and Flexible default open, Committed and Savings default closed so the day-to-day editing targets are visible and the set-and-forget commitments collapse out of the way. Primary Debt Target renders inside the Committed group after Debt Minimums. Every mutation appends a group-totals partial with OOB spans for all four group subtotals so the header stays in sync without a reload regardless of which section changed. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/routes.py | 22 +++++-- src/quartermaster/routes_month.py | 17 ++++- src/quartermaster/static/app.css | 64 +++++++++++++++++++ src/quartermaster/templates/index.html | 21 ++++-- src/quartermaster/templates/month.html | 25 ++++++-- .../partials/budget_group_totals.html | 3 + .../partials/month_group_totals.html | 7 ++ 7 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 src/quartermaster/templates/partials/budget_group_totals.html create mode 100644 src/quartermaster/templates/partials/month_group_totals.html diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index dc65812..926eb60 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -66,6 +66,14 @@ def _render_zero(request: Request, db: Session) -> HTMLResponse: ) +def _render_group_totals(request: Request, db: Session) -> HTMLResponse: + return templates.TemplateResponse( + request, + "partials/budget_group_totals.html", + {"groups": service.budget_group_views(db)}, + ) + + def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: body = base.body.decode() + "".join(e.body.decode() for e in extras) return HTMLResponse(body) @@ -73,7 +81,7 @@ def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: @router.get("/", response_class=HTMLResponse) def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: - sections = [_section_view(db, s) for s in Section] + groups = service.budget_group_views(db) target = service.get_debt_target(db) debt_minimums = service.list_entries(db, Section.debt_minimum) current_ym = month_service.current_year_month() @@ -82,7 +90,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: request, "index.html", { - "sections": sections, + "groups": groups, "target": target, "debt_minimums": debt_minimums, "current_year_month": current_ym, @@ -107,7 +115,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)] + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] if section == Section.debt_minimum: extras.append(_render_target(request, db)) return _append_oob(response, *extras) @@ -123,7 +134,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)] + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] if entry.section == Section.debt_minimum: extras.append(_render_target(request, db)) return _append_oob(response, *extras) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index 397501a..8faa748 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -83,6 +83,14 @@ def _render_zero(request: Request, month: Month) -> HTMLResponse: ) +def _render_group_totals(request: Request, month: Month) -> HTMLResponse: + return templates.TemplateResponse( + request, + "partials/month_group_totals.html", + {"groups": month_service.month_group_views(month)}, + ) + + def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: body = base.body.decode() + "".join(e.body.decode() for e in extras) return HTMLResponse(body) @@ -120,7 +128,7 @@ def view_month( "prev_year_month": prev_ym, "next_year_month": next_ym, "all_months": all_months, - "sections": _section_views(month), + "groups": month_service.month_group_views(month), "target": month_service.get_month_target(db, month), "debt_minimums": sorted( (e for e in month.entries if e.section == Section.debt_minimum), @@ -169,6 +177,7 @@ def add_month_entry( return _append_oob( _render_section(request, month, section), _render_zero(request, month), + _render_group_totals(request, month), ) @@ -184,7 +193,10 @@ def delete_month_entry( if section is None: raise HTTPException(status_code=404, detail="entry not found") db.refresh(month) - extras: list[HTMLResponse] = [_render_zero(request, month)] + extras: list[HTMLResponse] = [ + _render_zero(request, month), + _render_group_totals(request, month), + ] if section == Section.debt_minimum: extras.append(_render_target(request, db, month)) return _append_oob(_render_section(request, month, section), *extras) @@ -222,6 +234,7 @@ def update_month_entry( return _append_oob( _render_section(request, month, updated.section), _render_zero(request, month), + _render_group_totals(request, month), ) diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 216aa9a..8a9fb97 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -157,6 +157,70 @@ button[type=submit] { border-bottom-style: dashed; } +/* Section groups --------------------------------------------------------- */ + +details.group { + margin-top: 1rem; + border-top: 2px solid var(--ink); +} + +details.group:last-of-type { + border-bottom: 2px solid var(--ink); +} + +details.group > summary { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.6rem 0.5rem; + cursor: pointer; + list-style: none; + user-select: none; +} + +details.group > summary::-webkit-details-marker { display: none; } + +details.group > summary .chevron { + display: inline-block; + width: 0.8rem; + transition: transform 0.12s ease-out; + color: var(--muted); +} + +details.group > summary .chevron::before { + content: "\25B6"; /* black right-pointing triangle */ + font-size: 0.7rem; +} + +details.group[open] > summary .chevron { + transform: rotate(90deg); +} + +details.group > summary .group-name { + flex: 1; + font-size: 1.05rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +details.group > summary .group-total { + font-variant-numeric: tabular-nums; + font-weight: 700; + color: var(--accent); + font-size: 1.05rem; +} + +details.group > .section { + margin-top: 0.5rem; + margin-bottom: 1rem; + padding-left: 1rem; +} + +details.group > .section:last-child { + margin-bottom: 0.75rem; +} + /* Zero Amount widget ----------------------------------------------------- */ :root { diff --git a/src/quartermaster/templates/index.html b/src/quartermaster/templates/index.html index 71719f3..04e85e4 100644 --- a/src/quartermaster/templates/index.html +++ b/src/quartermaster/templates/index.html @@ -19,13 +19,20 @@ {% endif %} {% include "partials/budget_zero.html" %} - {% for section in sections %} - {% if section.section.value == 'debt_minimum' %} - {% include "partials/section.html" %} - {% include "partials/target_card.html" %} - {% else %} - {% include "partials/section.html" %} - {% endif %} + {% for g in groups %} +
+ + + {{ g.label }} + ${{ '%.2f' | format(g.total) }} + + {% for section in g.sections %} + {% include "partials/section.html" %} + {% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %} + {% include "partials/target_card.html" %} + {% endif %} + {% endfor %} +
{% endfor %} {% endblock %} diff --git a/src/quartermaster/templates/month.html b/src/quartermaster/templates/month.html index 3f8d31e..b4d5c8e 100644 --- a/src/quartermaster/templates/month.html +++ b/src/quartermaster/templates/month.html @@ -3,13 +3,24 @@
{% 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" %} - {% include "partials/month_target.html" %} - {% else %} - {% include "partials/month_section.html" %} - {% endif %} + {% for g in groups %} +
+ + + {{ g.label }} + + ${{ '%.2f' | format(g.total_applied) }} + / + ${{ '%.2f' | format(g.total_planned) }} + + + {% for section in g.sections %} + {% include "partials/month_section.html" %} + {% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %} + {% include "partials/month_target.html" %} + {% endif %} + {% endfor %} +
{% endfor %}
{% endblock %} diff --git a/src/quartermaster/templates/partials/budget_group_totals.html b/src/quartermaster/templates/partials/budget_group_totals.html new file mode 100644 index 0000000..4bed05b --- /dev/null +++ b/src/quartermaster/templates/partials/budget_group_totals.html @@ -0,0 +1,3 @@ +{% for g in groups -%} +${{ '%.2f' | format(g.total) }} +{% endfor -%} diff --git a/src/quartermaster/templates/partials/month_group_totals.html b/src/quartermaster/templates/partials/month_group_totals.html new file mode 100644 index 0000000..f63c06c --- /dev/null +++ b/src/quartermaster/templates/partials/month_group_totals.html @@ -0,0 +1,7 @@ +{% for g in groups -%} + + ${{ '%.2f' | format(g.total_applied) }} + / + ${{ '%.2f' | format(g.total_planned) }} + +{% endfor -%}