From 032c35c75e0c6da24fab09fa49747f2706e378f0 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:09 -0600 Subject: [PATCH 1/4] feat(groups): add sinking_fund section and define four-group layout Section enum gains sinking_fund with label "Sinking Funds". A new groups module maps each section to one of Income, Committed, Savings, Flexible and records the default open state per group. The existing section column is a plain VARCHAR(32) with no CHECK, so no schema migration is needed to accept the new value. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/groups.py | 47 +++++++++++++++++++++++++++++++++++++ src/quartermaster/models.py | 2 ++ 2 files changed, 49 insertions(+) create mode 100644 src/quartermaster/groups.py 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", } From 0c533d62edd6023d1dc5f0d2d72b0a67436a7efe Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:12 -0600 Subject: [PATCH 2/4] feat(groups): group views with subtotals for budget and month budget_group_views composes SectionViews into grouped dataclasses with a combined total and the default open flag. month_group_views does the same with planned and applied totals. Group order, labels, and section-to-group mapping all come from the groups module. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/month_service.py | 40 ++++++++++++++++++++++++++ src/quartermaster/service.py | 45 +++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) 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/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") From 4d9b64d760e9420c05dbb5828ff5e2260edc3fdd Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:18 -0600 Subject: [PATCH 3/4] feat(groups): render collapsible group details and OOB-swap subtotals Budget and month pages now wrap sections in native
blocks with summary rows showing the group name and subtotal. Income and Flexible default open, Committed and Savings default closed so the day-to-day editing targets are visible and the set-and-forget commitments collapse out of the way. Primary Debt Target renders inside the Committed group after Debt Minimums. Every mutation appends a group-totals partial with OOB spans for all four group subtotals so the header stays in sync without a reload regardless of which section changed. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/routes.py | 22 +++++-- src/quartermaster/routes_month.py | 17 ++++- src/quartermaster/static/app.css | 64 +++++++++++++++++++ src/quartermaster/templates/index.html | 21 ++++-- src/quartermaster/templates/month.html | 25 ++++++-- .../partials/budget_group_totals.html | 3 + .../partials/month_group_totals.html | 7 ++ 7 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 src/quartermaster/templates/partials/budget_group_totals.html create mode 100644 src/quartermaster/templates/partials/month_group_totals.html 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/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 -%} From 0ba7a19972e5ca0854451207904e0f3a21ae3c7a Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:21 -0600 Subject: [PATCH 4/4] test: cover group mapping, subtotals, default state, and OOB swaps Every section maps to a group. Group order and defaults match the spec. Budget and month subtotal calculations check out across seeded entries. Pages render the expected details ids, income is open by default, committed is closed. Mutations return OOB group total spans. Sinking Funds section is visible on both pages. Refs #11 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_groups.py | 164 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_groups.py 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