feat(zero): render Zero Amount header and OOB-swap on every mutation

Single large number on /, Planned + Applied pair on /month/YYYY-MM,
both colour-coded (green at zero, amber positive, red negative).
Every mutation route (add, delete, month entry update) appends the
zero widget to its response with hx-swap-oob so totals stay in sync
without a full page reload. An _append_oob helper replaces the ad-hoc
string concatenation the target card was already using.

Refs #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 12:06:42 -06:00
parent 5a65b4c524
commit 29c6594347
7 changed files with 128 additions and 12 deletions

View file

@ -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) @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] sections = [_section_view(db, s) for s in Section]
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()
zero = service.budget_zero(db)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"index.html", "index.html",
@ -72,6 +87,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
"debt_minimums": debt_minimums, "debt_minimums": debt_minimums,
"current_year_month": current_ym, "current_year_month": current_ym,
"all_months": month_service.list_months(db), "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) 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)]
if section == Section.debt_minimum: if section == Section.debt_minimum:
target_html = _render_target(request, db).body.decode() extras.append(_render_target(request, db))
response = HTMLResponse(response.body.decode() + target_html) return _append_oob(response, *extras)
return response
@router.delete("/entries/{entry_id}", response_class=HTMLResponse) @router.delete("/entries/{entry_id}", response_class=HTMLResponse)
@ -106,10 +123,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)]
if entry.section == Section.debt_minimum: if entry.section == Section.debt_minimum:
target_html = _render_target(request, db).body.decode() extras.append(_render_target(request, db))
response = HTMLResponse(response.body.decode() + target_html) return _append_oob(response, *extras)
return response
@router.post("/debt-target", response_class=HTMLResponse) @router.post("/debt-target", response_class=HTMLResponse)

View file

@ -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) @router.get("", response_class=HTMLResponse)
def view_month( def view_month(
year_month: str, year_month: str,
@ -92,6 +110,7 @@ def view_month(
"all_months": all_months, "all_months": all_months,
}, },
) )
zero = month_service.month_zero(month)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"month.html", "month.html",
@ -107,6 +126,9 @@ def view_month(
(e for e in month.entries if e.section == Section.debt_minimum), (e for e in month.entries if e.section == Section.debt_minimum),
key=lambda e: e.id, 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, month, section, clean_name, _parse_amount(planned)
) )
db.refresh(month) 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) @router.delete("/entries/{entry_id}", response_class=HTMLResponse)
@ -159,11 +184,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)
response = _render_section(request, month, section) extras: list[HTMLResponse] = [_render_zero(request, month)]
if section == Section.debt_minimum: if section == Section.debt_minimum:
target_html = _render_target(request, db, month).body.decode() extras.append(_render_target(request, db, month))
response = HTMLResponse(response.body.decode() + target_html) return _append_oob(_render_section(request, month, section), *extras)
return response
@router.post("/entries/{entry_id}", response_class=HTMLResponse) @router.post("/entries/{entry_id}", response_class=HTMLResponse)
@ -195,7 +219,10 @@ def update_month_entry(
if updated is None: if updated 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)
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) @router.post("/target", response_class=HTMLResponse)

View file

@ -157,6 +157,59 @@ button[type=submit] {
border-bottom-style: dashed; 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 ----------------------------------------------------------- */ /* Monthly view ----------------------------------------------------------- */
.month-nav { .month-nav {

View file

@ -18,6 +18,7 @@
</select> </select>
{% endif %} {% endif %}
</nav> </nav>
{% include "partials/budget_zero.html" %}
{% for section in sections %} {% for section in sections %}
{% if section.section.value == 'debt_minimum' %} {% if section.section.value == 'debt_minimum' %}
{% include "partials/section.html" %} {% include "partials/section.html" %}

View file

@ -2,6 +2,7 @@
{% block content %} {% block content %}
<div class="budget"> <div class="budget">
{% include "partials/month_nav.html" %} {% include "partials/month_nav.html" %}
{% include "partials/month_zero.html" %}
{% for section in sections %} {% for section in sections %}
{% if section.section.value == 'debt_minimum' %} {% if section.section.value == 'debt_minimum' %}
{% include "partials/month_section.html" %} {% include "partials/month_section.html" %}

View file

@ -0,0 +1,4 @@
<section class="zero-widget" id="zero-widget" hx-swap-oob="outerHTML">
<div class="zero-label">Zero Amount</div>
<div class="zero-value tone-{{ tone }}">${{ '%.2f' | format(zero) }}</div>
</section>

View file

@ -0,0 +1,13 @@
<section class="zero-widget zero-widget-pair" id="zero-widget" hx-swap-oob="outerHTML">
<div class="zero-label">Zero Amount</div>
<div class="zero-values">
<div class="zero-cell">
<div class="zero-sublabel">Planned</div>
<div class="zero-value tone-{{ planned_tone }}">${{ '%.2f' | format(zero.planned) }}</div>
</div>
<div class="zero-cell">
<div class="zero-sublabel">Applied</div>
<div class="zero-value tone-{{ applied_tone }}">${{ '%.2f' | format(zero.applied) }}</div>
</div>
</div>
</section>