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:
parent
0c533d62ed
commit
4d9b64d760
7 changed files with 139 additions and 20 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 %}>
|
||||||
|
<summary class="group-header">
|
||||||
|
<span class="chevron" aria-hidden="true"></span>
|
||||||
|
<span class="group-name">{{ g.label }}</span>
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}">${{ '%.2f' | format(g.total) }}</span>
|
||||||
|
</summary>
|
||||||
|
{% for section in g.sections %}
|
||||||
{% include "partials/section.html" %}
|
{% include "partials/section.html" %}
|
||||||
|
{% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/target_card.html" %}
|
{% include "partials/target_card.html" %}
|
||||||
{% else %}
|
|
||||||
{% include "partials/section.html" %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}>
|
||||||
|
<summary class="group-header">
|
||||||
|
<span class="chevron" aria-hidden="true"></span>
|
||||||
|
<span class="group-name">{{ g.label }}</span>
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}">
|
||||||
|
<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" %}
|
{% include "partials/month_section.html" %}
|
||||||
|
{% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/month_target.html" %}
|
{% include "partials/month_target.html" %}
|
||||||
{% else %}
|
|
||||||
{% include "partials/month_section.html" %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 -%}
|
||||||
|
|
@ -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 -%}
|
||||||
Loading…
Reference in a new issue