Zero Amount header at the top of budget and month pages #8
10 changed files with 310 additions and 12 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
|
|
|
||||||
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>
|
||||||
140
tests/test_zero_amount.py
Normal file
140
tests/test_zero_amount.py
Normal 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
|
||||||
Loading…
Reference in a new issue