Compare commits

...

3 commits

Author SHA1 Message Date
archeious
ce7e0f2b3f test: cover zero-amount math and OOB rendering
Service tests hit empty, positive, negative, and exactly-zero cases,
verify debt_target is excluded from the calculation, and confirm
month_zero responds to applied updates. Route tests assert the zero
widget appears OOB on every mutation with the expected tone class.

Refs #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:06:45 -06:00
archeious
29c6594347 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>
2026-04-17 12:06:42 -06:00
archeious
5a65b4c524 feat(zero): compute zero-amount for budget and month
budget_zero sums income minus every non-income section on the budget
config. month_zero returns both Planned and Applied versions over a
month's entries. zero_tone classifies a value as zero / positive /
negative so templates can pick a colour.

Refs #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:06:37 -06:00
10 changed files with 310 additions and 12 deletions

View file

@ -42,6 +42,12 @@ class MonthSectionView:
total_applied: Decimal total_applied: Decimal
@dataclass(frozen=True)
class ZeroAmounts:
planned: Decimal
applied: Decimal
def valid_year_month(year_month: str) -> bool: def valid_year_month(year_month: str) -> bool:
return bool(YEAR_MONTH_RE.match(year_month)) return bool(YEAR_MONTH_RE.match(year_month))
@ -124,6 +130,24 @@ def _rows(entries: list[MonthEntry]) -> list[MonthRow]:
return [MonthRow(entry=e, state=deviation_state(e)) for e in entries] return [MonthRow(entry=e, state=deviation_state(e)) for e in entries]
def month_zero(month: Month) -> ZeroAmounts:
income_planned = Decimal("0")
income_applied = Decimal("0")
other_planned = Decimal("0")
other_applied = Decimal("0")
for entry in month.entries:
if entry.section == Section.income:
income_planned += entry.planned
income_applied += entry.applied
else:
other_planned += entry.planned
other_applied += entry.applied
return ZeroAmounts(
planned=(income_planned - other_planned).quantize(Decimal("0.01")),
applied=(income_applied - other_applied).quantize(Decimal("0.01")),
)
def section_view(month: Month, section: Section, label: str) -> MonthSectionView: def section_view(month: Month, section: Section, label: str) -> MonthSectionView:
entries = [e for e in month.entries if e.section == section] entries = [e for e in month.entries if e.section == section]
entries.sort(key=lambda e: e.id) entries.sort(key=lambda e: e.id)

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

@ -9,6 +9,12 @@ from sqlalchemy.orm import Session
from quartermaster.models import DebtTarget, Entry, Section from quartermaster.models import DebtTarget, Entry, Section
def zero_tone(value: Decimal) -> str:
if value == 0:
return "zero"
return "positive" if value > 0 else "negative"
@dataclass(frozen=True) @dataclass(frozen=True)
class SectionView: class SectionView:
section: Section section: Section
@ -26,6 +32,18 @@ def section_total(entries: list[Entry]) -> Decimal:
return sum((e.amount for e in entries), Decimal("0")) return sum((e.amount for e in entries), Decimal("0"))
def budget_zero(db: Session) -> Decimal:
stmt = select(Entry)
total_income = Decimal("0")
total_non_income = Decimal("0")
for entry in db.scalars(stmt):
if entry.section == Section.income:
total_income += entry.amount
else:
total_non_income += entry.amount
return (total_income - total_non_income).quantize(Decimal("0.01"))
def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry: def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry:
entry = Entry(section=section, name=name.strip(), amount=amount) entry = Entry(section=section, name=name.strip(), amount=amount)
db.add(entry) db.add(entry)

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>

140
tests/test_zero_amount.py Normal file
View file

@ -0,0 +1,140 @@
from __future__ import annotations
from decimal import Decimal
from quartermaster import month_service, service
from quartermaster.models import Section
def test_zero_tone_classification():
assert service.zero_tone(Decimal("0")) == "zero"
assert service.zero_tone(Decimal("0.00")) == "zero"
assert service.zero_tone(Decimal("1.00")) == "positive"
assert service.zero_tone(Decimal("-0.01")) == "negative"
def test_budget_zero_empty_is_zero(db):
assert service.budget_zero(db) == Decimal("0.00")
def test_budget_zero_positive_when_unassigned(db):
service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
assert service.budget_zero(db) == Decimal("1300.00")
def test_budget_zero_negative_when_overbudget(db):
service.add_entry(db, Section.income, "Paycheck", Decimal("1000.00"))
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
assert service.budget_zero(db) == Decimal("-200.00")
def test_budget_zero_exactly_zero(db):
service.add_entry(db, Section.income, "Paycheck", Decimal("1500.00"))
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1000.00"))
service.add_entry(db, Section.food, "Groceries", Decimal("500.00"))
assert service.budget_zero(db) == Decimal("0.00")
def test_budget_zero_ignores_debt_target(db):
# target is a pointer, not a totalable section; the math does not touch it
dm = service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00"))
service.add_entry(db, Section.income, "Paycheck", Decimal("100.00"))
service.set_debt_target(db, dm.id)
assert service.budget_zero(db) == Decimal("60.00")
def _seed_budget(db):
service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00"))
def test_month_zero_starts_planned_nonzero_applied_zero(db):
_seed_budget(db)
month = month_service.create_month(db, "2026-04")
z = month_service.month_zero(month)
# income 2500 - (1200 + 40) = 1260
assert z.planned == Decimal("1260.00")
# applied all 0 on fresh snapshot
assert z.applied == Decimal("2500.00") * 0 - Decimal("0")
assert z.applied == Decimal("0.00")
def test_month_zero_reflects_applied_updates(db):
_seed_budget(db)
month = month_service.create_month(db, "2026-04")
income_entry = next(e for e in month.entries if e.origin_name == "Paycheck")
rent_entry = next(e for e in month.entries if e.origin_name == "Rent")
month_service.update_month_entry(
db, month, income_entry.id, applied=Decimal("2500.00")
)
month_service.update_month_entry(
db, month, rent_entry.id, applied=Decimal("1200.00")
)
db.refresh(month)
z = month_service.month_zero(month)
# applied income 2500 - applied (1200 + 0) = 1300
assert z.applied == Decimal("1300.00")
def test_add_entry_returns_zero_widget_oob(client):
response = client.post(
"/sections/income/entries",
data={"name": "Paycheck", "amount": "2500.00"},
)
assert response.status_code == 200
assert 'id="zero-widget"' in response.text
assert 'hx-swap-oob="outerHTML"' in response.text
assert "tone-positive" in response.text
def test_delete_entry_returns_zero_widget_oob(client):
client.post(
"/sections/income/entries",
data={"name": "Paycheck", "amount": "2500.00"},
)
response = client.delete("/entries/1")
assert response.status_code == 200
assert 'id="zero-widget"' in response.text
assert "tone-zero" in response.text
def test_budget_page_renders_zero_widget(client):
response = client.get("/")
assert response.status_code == 200
assert 'id="zero-widget"' in response.text
assert "Zero Amount" in response.text
def test_month_entry_update_returns_zero_widget_oob(client):
client.post(
"/sections/income/entries",
data={"name": "Paycheck", "amount": "2500.00"},
)
client.post(
"/sections/fixed_bill/entries",
data={"name": "Rent", "amount": "1200.00"},
)
client.post("/month/2026-04/create")
# updating rent applied should bring the month zero widget back with new values
response = client.post(
"/month/2026-04/entries/2", data={"applied": "1200.00"}
)
assert response.status_code == 200
assert 'id="zero-widget"' in response.text
# applied: 0 - 1200 = -1200, negative tone
assert "tone-negative" in response.text
def test_month_page_renders_paired_zero_widget(client):
client.post(
"/sections/income/entries",
data={"name": "Paycheck", "amount": "2500.00"},
)
client.post("/month/2026-04/create")
response = client.get("/month/2026-04")
assert response.status_code == 200
assert "zero-widget-pair" in response.text
assert response.text.count("Planned") >= 1
assert response.text.count("Applied") >= 1