Compare commits

...

4 commits

Author SHA1 Message Date
archeious
0ba7a19972 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) <noreply@anthropic.com>
2026-04-17 12:44:21 -06:00
archeious
4d9b64d760 feat(groups): render collapsible group details and OOB-swap subtotals
Budget and month pages now wrap sections in native <details> 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) <noreply@anthropic.com>
2026-04-17 12:44:18 -06:00
archeious
0c533d62ed 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) <noreply@anthropic.com>
2026-04-17 12:44:12 -06:00
archeious
032c35c75e 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) <noreply@anthropic.com>
2026-04-17 12:44:09 -06:00
12 changed files with 436 additions and 21 deletions

View file

@ -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]

View file

@ -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",
}

View file

@ -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)

View file

@ -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)

View file

@ -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),
)

View file

@ -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")

View file

@ -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 {

View file

@ -19,13 +19,20 @@
{% endif %}
</nav>
{% include "partials/budget_zero.html" %}
{% for section in sections %}
{% if section.section.value == 'debt_minimum' %}
{% for g in groups %}
<details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
<summary class="group-header">
<span class="chevron" aria-hidden="true"></span>
<span class="group-name">{{ g.label }}</span>
<span class="group-total" id="group-total-{{ g.group.value }}">${{ '%.2f' | format(g.total) }}</span>
</summary>
{% 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" %}
{% else %}
{% include "partials/section.html" %}
{% endif %}
{% endfor %}
</details>
{% endfor %}
</div>
{% endblock %}

View file

@ -3,13 +3,24 @@
<div class="budget">
{% include "partials/month_nav.html" %}
{% include "partials/month_zero.html" %}
{% for section in sections %}
{% if section.section.value == 'debt_minimum' %}
{% for g in groups %}
<details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
<summary class="group-header">
<span class="chevron" aria-hidden="true"></span>
<span class="group-name">{{ g.label }}</span>
<span class="group-total" id="group-total-{{ g.group.value }}">
<span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
</span>
</summary>
{% 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" %}
{% else %}
{% include "partials/month_section.html" %}
{% endif %}
{% endfor %}
</details>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,3 @@
{% for g in groups -%}
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">${{ '%.2f' | format(g.total) }}</span>
{% endfor -%}

View file

@ -0,0 +1,7 @@
{% for g in groups -%}
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">
<span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
<span class="divider">/</span>
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
</span>
{% endfor -%}

164
tests/test_groups.py Normal file
View file

@ -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 <details class="group"> 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