diff --git a/src/quartermaster/groups.py b/src/quartermaster/groups.py new file mode 100644 index 0000000..650d4ab --- /dev/null +++ b/src/quartermaster/groups.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import enum + +from quartermaster.models import Section + + +class Group(str, enum.Enum): + income = "income" + committed = "committed" + savings = "savings" + flexible = "flexible" + + +GROUP_LABELS: dict[Group, str] = { + Group.income: "Income", + Group.committed: "Committed", + Group.savings: "Savings", + Group.flexible: "Flexible", +} + + +GROUP_OF_SECTION: dict[Section, Group] = { + Section.income: Group.income, + Section.fixed_bill: Group.committed, + Section.debt_minimum: Group.committed, + Section.sinking_fund: Group.savings, + Section.food: Group.flexible, + Section.subscription: Group.flexible, + Section.other: Group.flexible, +} + + +GROUP_DEFAULT_OPEN: dict[Group, bool] = { + Group.income: True, + Group.committed: False, + Group.savings: False, + Group.flexible: True, +} + + +def sections_in_group(group: Group) -> list[Section]: + return [s for s in Section if GROUP_OF_SECTION[s] == group] + + +def group_order() -> list[Group]: + return [Group.income, Group.committed, Group.savings, Group.flexible] diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 8fb87c1..ee81008 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -24,6 +24,7 @@ class Section(str, enum.Enum): food = "food" subscription = "subscription" other = "other" + sinking_fund = "sinking_fund" SECTION_LABELS: dict[Section, str] = { @@ -33,6 +34,7 @@ SECTION_LABELS: dict[Section, str] = { Section.food: "Food and Essentials", Section.subscription: "Subscriptions", Section.other: "Other", + Section.sinking_fund: "Sinking Funds", } diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index c55e5bd..92e7b9f 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -9,7 +9,15 @@ from enum import Enum from sqlalchemy import select from sqlalchemy.orm import Session +from quartermaster.groups import ( + GROUP_DEFAULT_OPEN, + GROUP_LABELS, + Group, + group_order, + sections_in_group, +) from quartermaster.models import ( + SECTION_LABELS, DebtTarget, Entry, Month, @@ -48,6 +56,16 @@ class ZeroAmounts: applied: Decimal +@dataclass(frozen=True) +class MonthGroupView: + group: Group + label: str + default_open: bool + sections: list[MonthSectionView] + total_planned: Decimal + total_applied: Decimal + + def valid_year_month(year_month: str) -> bool: return bool(YEAR_MONTH_RE.match(year_month)) @@ -148,6 +166,28 @@ def month_zero(month: Month) -> ZeroAmounts: ) +def month_group_views(month: Month) -> list[MonthGroupView]: + views: list[MonthGroupView] = [] + for group in group_order(): + sections = [ + section_view(month, s, SECTION_LABELS[s]) + for s in sections_in_group(group) + ] + planned = sum((sv.total_planned for sv in sections), Decimal("0")) + applied = sum((sv.total_applied for sv in sections), Decimal("0")) + views.append( + MonthGroupView( + group=group, + label=GROUP_LABELS[group], + default_open=GROUP_DEFAULT_OPEN[group], + sections=sections, + total_planned=planned, + total_applied=applied, + ) + ) + return views + + def section_view(month: Month, section: Section, label: str) -> MonthSectionView: entries = [e for e in month.entries if e.section == section] entries.sort(key=lambda e: e.id) diff --git a/src/quartermaster/routes.py b/src/quartermaster/routes.py index dc65812..926eb60 100644 --- a/src/quartermaster/routes.py +++ b/src/quartermaster/routes.py @@ -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: body = base.body.decode() + "".join(e.body.decode() for e in extras) return HTMLResponse(body) @@ -73,7 +81,7 @@ def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse: @router.get("/", response_class=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) debt_minimums = service.list_entries(db, Section.debt_minimum) current_ym = month_service.current_year_month() @@ -82,7 +90,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse: request, "index.html", { - "sections": sections, + "groups": groups, "target": target, "debt_minimums": debt_minimums, "current_year_month": current_ym, @@ -107,7 +115,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)] + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] if section == Section.debt_minimum: extras.append(_render_target(request, db)) return _append_oob(response, *extras) @@ -123,7 +134,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)] + extras: list[HTMLResponse] = [ + _render_zero(request, db), + _render_group_totals(request, db), + ] if entry.section == Section.debt_minimum: extras.append(_render_target(request, db)) return _append_oob(response, *extras) diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index 397501a..8faa748 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -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: body = base.body.decode() + "".join(e.body.decode() for e in extras) return HTMLResponse(body) @@ -120,7 +128,7 @@ def view_month( "prev_year_month": prev_ym, "next_year_month": next_ym, "all_months": all_months, - "sections": _section_views(month), + "groups": month_service.month_group_views(month), "target": month_service.get_month_target(db, month), "debt_minimums": sorted( (e for e in month.entries if e.section == Section.debt_minimum), @@ -169,6 +177,7 @@ def add_month_entry( return _append_oob( _render_section(request, month, section), _render_zero(request, month), + _render_group_totals(request, month), ) @@ -184,7 +193,10 @@ def delete_month_entry( if section is None: raise HTTPException(status_code=404, detail="entry not found") 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: extras.append(_render_target(request, db, month)) return _append_oob(_render_section(request, month, section), *extras) @@ -222,6 +234,7 @@ def update_month_entry( return _append_oob( _render_section(request, month, updated.section), _render_zero(request, month), + _render_group_totals(request, month), ) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index e6a97cc..7ed9fb7 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -6,7 +6,14 @@ from decimal import Decimal from sqlalchemy import select from sqlalchemy.orm import Session -from quartermaster.models import DebtTarget, Entry, Section +from quartermaster.groups import ( + GROUP_DEFAULT_OPEN, + GROUP_LABELS, + Group, + group_order, + sections_in_group, +) +from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section def zero_tone(value: Decimal) -> str: @@ -23,6 +30,15 @@ class SectionView: total: Decimal +@dataclass(frozen=True) +class BudgetGroupView: + group: Group + label: str + default_open: bool + sections: list[SectionView] + total: Decimal + + def list_entries(db: Session, section: Section) -> list[Entry]: stmt = select(Entry).where(Entry.section == section).order_by(Entry.id) return list(db.scalars(stmt)) @@ -32,6 +48,33 @@ def section_total(entries: list[Entry]) -> Decimal: return sum((e.amount for e in entries), Decimal("0")) +def section_view(db: Session, section: Section) -> SectionView: + entries = list_entries(db, section) + return SectionView( + section=section, + label=SECTION_LABELS[section], + entries=entries, + total=section_total(entries), + ) + + +def budget_group_views(db: Session) -> list[BudgetGroupView]: + views: list[BudgetGroupView] = [] + for group in group_order(): + sections = [section_view(db, s) for s in sections_in_group(group)] + total = sum((sv.total for sv in sections), Decimal("0")) + views.append( + BudgetGroupView( + group=group, + label=GROUP_LABELS[group], + default_open=GROUP_DEFAULT_OPEN[group], + sections=sections, + total=total, + ) + ) + return views + + def budget_zero(db: Session) -> Decimal: stmt = select(Entry) total_income = Decimal("0") diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 216aa9a..8a9fb97 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -157,6 +157,70 @@ button[type=submit] { 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 ----------------------------------------------------- */ :root { diff --git a/src/quartermaster/templates/index.html b/src/quartermaster/templates/index.html index 71719f3..04e85e4 100644 --- a/src/quartermaster/templates/index.html +++ b/src/quartermaster/templates/index.html @@ -19,13 +19,20 @@ {% endif %} {% include "partials/budget_zero.html" %} - {% for section in sections %} - {% if section.section.value == 'debt_minimum' %} - {% include "partials/section.html" %} - {% include "partials/target_card.html" %} - {% else %} - {% include "partials/section.html" %} - {% endif %} + {% for g in groups %} +
+ + + {{ g.label }} + ${{ '%.2f' | format(g.total) }} + + {% for section in g.sections %} + {% include "partials/section.html" %} + {% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %} + {% include "partials/target_card.html" %} + {% endif %} + {% endfor %} +
{% endfor %} {% endblock %} diff --git a/src/quartermaster/templates/month.html b/src/quartermaster/templates/month.html index 3f8d31e..b4d5c8e 100644 --- a/src/quartermaster/templates/month.html +++ b/src/quartermaster/templates/month.html @@ -3,13 +3,24 @@
{% 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" %} - {% include "partials/month_target.html" %} - {% else %} - {% include "partials/month_section.html" %} - {% endif %} + {% for g in groups %} +
+ + + {{ g.label }} + + ${{ '%.2f' | format(g.total_applied) }} + / + ${{ '%.2f' | format(g.total_planned) }} + + + {% for section in g.sections %} + {% include "partials/month_section.html" %} + {% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %} + {% include "partials/month_target.html" %} + {% endif %} + {% endfor %} +
{% endfor %}
{% endblock %} diff --git a/src/quartermaster/templates/partials/budget_group_totals.html b/src/quartermaster/templates/partials/budget_group_totals.html new file mode 100644 index 0000000..4bed05b --- /dev/null +++ b/src/quartermaster/templates/partials/budget_group_totals.html @@ -0,0 +1,3 @@ +{% for g in groups -%} +${{ '%.2f' | format(g.total) }} +{% endfor -%} diff --git a/src/quartermaster/templates/partials/month_group_totals.html b/src/quartermaster/templates/partials/month_group_totals.html new file mode 100644 index 0000000..f63c06c --- /dev/null +++ b/src/quartermaster/templates/partials/month_group_totals.html @@ -0,0 +1,7 @@ +{% for g in groups -%} + + ${{ '%.2f' | format(g.total_applied) }} + / + ${{ '%.2f' | format(g.total_planned) }} + +{% endfor -%} diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..d877f7a --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from decimal import Decimal + +from quartermaster import month_service, service +from quartermaster.groups import ( + GROUP_DEFAULT_OPEN, + GROUP_OF_SECTION, + Group, + group_order, + sections_in_group, +) +from quartermaster.models import Section + + +def test_every_section_maps_to_a_group(): + for s in Section: + assert s in GROUP_OF_SECTION, f"{s} is not assigned to a group" + + +def test_group_order_is_stable(): + assert group_order() == [ + Group.income, + Group.committed, + Group.savings, + Group.flexible, + ] + + +def test_sinking_fund_is_in_savings_group(): + assert GROUP_OF_SECTION[Section.sinking_fund] == Group.savings + assert Section.sinking_fund in sections_in_group(Group.savings) + + +def test_defaults_match_spec(): + assert GROUP_DEFAULT_OPEN[Group.income] is True + assert GROUP_DEFAULT_OPEN[Group.committed] is False + assert GROUP_DEFAULT_OPEN[Group.savings] is False + assert GROUP_DEFAULT_OPEN[Group.flexible] is True + + +def test_committed_group_contains_fixed_and_debt_minimum(): + committed_sections = sections_in_group(Group.committed) + assert Section.fixed_bill in committed_sections + assert Section.debt_minimum in committed_sections + # debt_target is a pointer, not a section; not part of the enum + assert len(committed_sections) == 2 + + +def test_budget_group_views_totals(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")) + service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00")) + service.add_entry(db, Section.food, "Groceries", Decimal("500.00")) + views = {v.group: v for v in service.budget_group_views(db)} + assert views[Group.income].total == Decimal("2500.00") + assert views[Group.committed].total == Decimal("1240.00") + assert views[Group.savings].total == Decimal("300.00") + assert views[Group.flexible].total == Decimal("500.00") + + +def test_month_group_views_planned_and_applied(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.sinking_fund, "Emergency", Decimal("300.00")) + month = month_service.create_month(db, "2026-04") + # seed applied values + rent = next(e for e in month.entries if e.origin_name == "Rent") + month_service.update_month_entry( + db, month, rent.id, applied=Decimal("1200.00") + ) + db.refresh(month) + views = {v.group: v for v in month_service.month_group_views(month)} + assert views[Group.income].total_planned == Decimal("2500.00") + assert views[Group.income].total_applied == Decimal("0.00") + assert views[Group.committed].total_planned == Decimal("1200.00") + assert views[Group.committed].total_applied == Decimal("1200.00") + assert views[Group.savings].total_planned == Decimal("300.00") + assert views[Group.savings].total_applied == Decimal("0.00") + assert views[Group.flexible].total_planned == Decimal("0.00") + + +def test_budget_page_renders_details_groups(client): + response = client.get("/") + assert response.status_code == 200 + # four
blocks + assert response.text.count('class="group"') >= 4 + # default open state: income and flexible open, committed and savings closed + assert 'id="group-income"' in response.text + assert 'id="group-committed"' in response.text + assert 'id="group-savings"' in response.text + assert 'id="group-flexible"' in response.text + # income has " open" in its details tag; committed does not + income_tag_start = response.text.index('id="group-income"') + income_chunk = response.text[income_tag_start - 80 : income_tag_start + 80] + assert " open" in income_chunk + committed_tag_start = response.text.index('id="group-committed"') + committed_chunk = response.text[ + committed_tag_start - 80 : committed_tag_start + 80 + ] + assert " open" not in committed_chunk + + +def test_add_entry_returns_group_total_oob(client): + response = client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + assert response.status_code == 200 + assert 'id="group-total-income"' in response.text + assert 'hx-swap-oob="outerHTML"' in response.text + + +def test_sinking_fund_entry_ends_up_in_savings_group_total(client): + response = client.post( + "/sections/sinking_fund/entries", + data={"name": "Emergency", "amount": "500.00"}, + ) + assert response.status_code == 200 + # OOB response contains group-total-savings with 500 + assert 'id="group-total-savings"' in response.text + assert "$500.00" in response.text + + +def test_month_page_renders_group_headers_with_paired_totals(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 'id="group-committed"' in response.text + assert 'id="group-total-income"' in response.text + + +def test_month_mutation_returns_group_total_oob(client): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "2500.00"}, + ) + client.post("/month/2026-04/create") + response = client.post( + "/month/2026-04/entries/1", data={"applied": "2500.00"} + ) + assert response.status_code == 200 + assert 'id="group-total-income"' in response.text + + +def test_sinking_fund_section_shows_on_budget_page(client): + response = client.get("/") + assert "Sinking Funds" in response.text + + +def test_sinking_fund_section_shows_on_month_page(client): + client.post( + "/sections/sinking_fund/entries", + data={"name": "Emergency", "amount": "500.00"}, + ) + client.post("/month/2026-04/create") + response = client.get("/month/2026-04") + assert "Sinking Funds" in response.text + assert "Emergency" in response.text