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:
parent
5a65b4c524
commit
29c6594347
7 changed files with 128 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
</select>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% include "partials/budget_zero.html" %}
|
||||
{% for section in sections %}
|
||||
{% if section.section.value == 'debt_minimum' %}
|
||||
{% include "partials/section.html" %}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
{% block content %}
|
||||
<div class="budget">
|
||||
{% 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" %}
|
||||
|
|
|
|||
4
src/quartermaster/templates/partials/budget_zero.html
Normal file
4
src/quartermaster/templates/partials/budget_zero.html
Normal 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>
|
||||
13
src/quartermaster/templates/partials/month_zero.html
Normal file
13
src/quartermaster/templates/partials/month_zero.html
Normal 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>
|
||||
Loading…
Reference in a new issue