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 %}
+