feat(groups): render collapsible group details and OOB-swap subtotals

Budget and month pages now wrap sections in native <details> 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) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 12:44:18 -06:00
parent 0c533d62ed
commit 4d9b64d760
7 changed files with 139 additions and 20 deletions

View file

@ -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: def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
body = base.body.decode() + "".join(e.body.decode() for e in extras) body = base.body.decode() + "".join(e.body.decode() for e in extras)
return HTMLResponse(body) return HTMLResponse(body)
@ -73,7 +81,7 @@ def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(get_session)) -> 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) target = service.get_debt_target(db)
debt_minimums = service.list_entries(db, Section.debt_minimum) debt_minimums = service.list_entries(db, Section.debt_minimum)
current_ym = month_service.current_year_month() current_ym = month_service.current_year_month()
@ -82,7 +90,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
request, request,
"index.html", "index.html",
{ {
"sections": sections, "groups": groups,
"target": target, "target": target,
"debt_minimums": debt_minimums, "debt_minimums": debt_minimums,
"current_year_month": current_ym, "current_year_month": current_ym,
@ -107,7 +115,10 @@ def create_entry(
parsed = _parse_amount(amount) parsed = _parse_amount(amount)
service.add_entry(db, section, clean_name, parsed) service.add_entry(db, section, clean_name, parsed)
response = _render_section(request, db, section) 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: if section == Section.debt_minimum:
extras.append(_render_target(request, db)) extras.append(_render_target(request, db))
return _append_oob(response, *extras) return _append_oob(response, *extras)
@ -123,7 +134,10 @@ def remove_entry(
if entry is None: if entry is None:
raise HTTPException(status_code=404, detail="entry not found") raise HTTPException(status_code=404, detail="entry not found")
response = _render_section(request, db, entry.section) 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: if entry.section == Section.debt_minimum:
extras.append(_render_target(request, db)) extras.append(_render_target(request, db))
return _append_oob(response, *extras) return _append_oob(response, *extras)

View file

@ -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: def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
body = base.body.decode() + "".join(e.body.decode() for e in extras) body = base.body.decode() + "".join(e.body.decode() for e in extras)
return HTMLResponse(body) return HTMLResponse(body)
@ -120,7 +128,7 @@ def view_month(
"prev_year_month": prev_ym, "prev_year_month": prev_ym,
"next_year_month": next_ym, "next_year_month": next_ym,
"all_months": all_months, "all_months": all_months,
"sections": _section_views(month), "groups": month_service.month_group_views(month),
"target": month_service.get_month_target(db, month), "target": month_service.get_month_target(db, month),
"debt_minimums": sorted( "debt_minimums": sorted(
(e for e in month.entries if e.section == Section.debt_minimum), (e for e in month.entries if e.section == Section.debt_minimum),
@ -169,6 +177,7 @@ def add_month_entry(
return _append_oob( return _append_oob(
_render_section(request, month, section), _render_section(request, month, section),
_render_zero(request, month), _render_zero(request, month),
_render_group_totals(request, month),
) )
@ -184,7 +193,10 @@ def delete_month_entry(
if section is None: if section is None:
raise HTTPException(status_code=404, detail="entry not found") raise HTTPException(status_code=404, detail="entry not found")
db.refresh(month) 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: if section == Section.debt_minimum:
extras.append(_render_target(request, db, month)) extras.append(_render_target(request, db, month))
return _append_oob(_render_section(request, month, section), *extras) return _append_oob(_render_section(request, month, section), *extras)
@ -222,6 +234,7 @@ def update_month_entry(
return _append_oob( return _append_oob(
_render_section(request, month, updated.section), _render_section(request, month, updated.section),
_render_zero(request, month), _render_zero(request, month),
_render_group_totals(request, month),
) )

View file

@ -157,6 +157,70 @@ button[type=submit] {
border-bottom-style: dashed; 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 ----------------------------------------------------- */ /* Zero Amount widget ----------------------------------------------------- */
:root { :root {

View file

@ -19,13 +19,20 @@
{% endif %} {% endif %}
</nav> </nav>
{% include "partials/budget_zero.html" %} {% include "partials/budget_zero.html" %}
{% for section in sections %} {% for g in groups %}
{% if section.section.value == 'debt_minimum' %} <details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
{% include "partials/section.html" %} <summary class="group-header">
{% include "partials/target_card.html" %} <span class="chevron" aria-hidden="true"></span>
{% else %} <span class="group-name">{{ g.label }}</span>
{% include "partials/section.html" %} <span class="group-total" id="group-total-{{ g.group.value }}">${{ '%.2f' | format(g.total) }}</span>
{% endif %} </summary>
{% 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 %}
</details>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,13 +3,24 @@
<div class="budget"> <div class="budget">
{% include "partials/month_nav.html" %} {% include "partials/month_nav.html" %}
{% include "partials/month_zero.html" %} {% include "partials/month_zero.html" %}
{% for section in sections %} {% for g in groups %}
{% if section.section.value == 'debt_minimum' %} <details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
{% include "partials/month_section.html" %} <summary class="group-header">
{% include "partials/month_target.html" %} <span class="chevron" aria-hidden="true"></span>
{% else %} <span class="group-name">{{ g.label }}</span>
{% include "partials/month_section.html" %} <span class="group-total" id="group-total-{{ g.group.value }}">
{% endif %} <span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
</span>
</summary>
{% 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 %}
</details>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,3 @@
{% for g in groups -%}
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">${{ '%.2f' | format(g.total) }}</span>
{% endfor -%}

View file

@ -0,0 +1,7 @@
{% for g in groups -%}
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">
<span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
</span>
{% endfor -%}