Skip to content

Commit

Permalink
Feature/cash flow tags (#258)
Browse files Browse the repository at this point in the history
* Added tag breakdown to cash flow page

* Updated tests
  • Loading branch information
WattsUp authored Jan 20, 2025
1 parent e73135e commit a658d65
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 3 deletions.
53 changes: 50 additions & 3 deletions nummus/controllers/cash_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ class CategoryContext(TypedDict):
TransactionSplit.category_id,
func.sum(TransactionSplit.amount),
)
.where(TransactionSplit.account_id.in_(ids))
.where(TransactionSplit.date_ord >= start_ord)
.where(TransactionSplit.date_ord <= end_ord)
.where(
TransactionSplit.account_id.in_(ids),
TransactionSplit.date_ord >= start_ord,
TransactionSplit.date_ord <= end_ord,
)
.group_by(TransactionSplit.category_id)
)
income_categorized: list[CategoryContext] = []
Expand Down Expand Up @@ -140,6 +142,49 @@ class CategoryContext(TypedDict):
key=lambda item: item["amount"],
)

query = (
s.query(TransactionSplit)
.with_entities(
TransactionSplit.tag,
func.sum(TransactionSplit.amount),
)
.where(
TransactionSplit.account_id.in_(ids),
TransactionSplit.date_ord >= start_ord,
TransactionSplit.date_ord <= end_ord,
TransactionSplit.tag.is_not(None),
)
.group_by(TransactionSplit.tag)
)
income_tagged: list[CategoryContext] = []
expense_tagged: list[CategoryContext] = []
for tag, amount in query.yield_per(YIELD_PER):
tag: str
if amount > 0:
income_tagged.append(
{
"name": tag,
"emoji_name": tag,
"amount": amount,
},
)
else:
expense_tagged.append(
{
"name": tag,
"emoji_name": tag,
"amount": amount,
},
)
income_tagged = sorted(
income_tagged,
key=lambda item: -item["amount"],
)
expense_tagged = sorted(
expense_tagged,
key=lambda item: item["amount"],
)

# For the timeseries,
# If n > 400, sum by years and make bars
# elif n > 80, sum by months and make bars
Expand Down Expand Up @@ -209,6 +254,8 @@ class CategoryContext(TypedDict):
"total_expense": total_expense,
"income_categorized": income_categorized,
"expense_categorized": expense_categorized,
"income_tagged": income_tagged,
"expense_tagged": expense_tagged,
"chart_bars": chart_bars,
"labels": labels,
"date_mode": date_mode,
Expand Down
72 changes: 72 additions & 0 deletions nummus/static/src/cash-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const cashFlow = {
chartExpense: null,
chartPieIncome: null,
chartPieExpense: null,
chartPieIncomeTag: null,
chartPieExpenseTag: null,
/**
* Create Cash Flow Chart
*
Expand All @@ -26,6 +28,14 @@ const cashFlow = {
a.amount = -Number(a.amount);
return a;
});
const incomeTagged = raw.income_tagged.map(a => {
a.amount = Number(a.amount);
return a;
});
const expenseTagged = raw.expense_tagged.map(a => {
a.amount = -Number(a.amount);
return a;
});

// Set a color for each category
incomeCategorized.forEach((a, i) => {
Expand All @@ -37,6 +47,16 @@ const cashFlow = {
a.color = c;
});

// Set a color for each tag
incomeTagged.forEach((a, i) => {
const c = getChartColor(i);
a.color = c;
});
expenseTagged.forEach((a, i) => {
const c = getChartColor(i);
a.color = c;
});

if (this.chartBars != chartBars) {
// Destroy all existing charts
if (this.chartTotal) this.chartTotal.destroy();
Expand Down Expand Up @@ -131,6 +151,20 @@ const cashFlow = {
this.createBreakdown(breakdown, expenseCategorized);
}

{
const breakdown = document.getElementById('income-tag-breakdown');
if (this.chartPieIncomeTag)
pluginHoverHighlight.removeListeners(this.chartPieIncomeTag);
this.createBreakdown(breakdown, incomeTagged);
}

{
const breakdown = document.getElementById('expense-tag-breakdown');
if (this.chartPieExpenseTag)
pluginHoverHighlight.removeListeners(this.chartPieExpenseTag);
this.createBreakdown(breakdown, expenseTagged);
}

{
const canvas = document.getElementById('income-pie-chart-canvas');
const ctx = canvas.getContext('2d');
Expand Down Expand Up @@ -167,6 +201,44 @@ const cashFlow = {
}
}

{
const canvas =
document.getElementById('income-tag-pie-chart-canvas');
const ctx = canvas.getContext('2d');
if (this.chartPieIncomeTag && ctx == this.chartPieIncomeTag.ctx) {
nummusChart.updatePie(this.chartPieIncomeTag, incomeTagged);
pluginHoverHighlight.addListeners(this.chartPieIncomeTag);
} else {
const plugins = [
[pluginHoverHighlight, {parent: 'income-tag-breakdown'}],
];
this.chartPieIncomeTag = nummusChart.createPie(
ctx,
incomeTagged,
plugins,
);
}
}

{
const canvas =
document.getElementById('expense-tag-pie-chart-canvas');
const ctx = canvas.getContext('2d');
if (this.chartPieExpenseTag && ctx == this.chartPieExpenseTag.ctx) {
nummusChart.updatePie(this.chartPieExpenseTag, expenseTagged);
pluginHoverHighlight.addListeners(this.chartPieExpenseTag);
} else {
const plugins = [
[pluginHoverHighlight, {parent: 'expense-tag-breakdown'}],
];
this.chartPieExpenseTag = nummusChart.createPie(
ctx,
expenseTagged,
plugins,
);
}
}

this.chartBars = chartBars;
},
/**
Expand Down
20 changes: 20 additions & 0 deletions nummus/templates/cash-flow/index-content.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
</div>
{% include "shared/spinner.jinja" %}
</div>
<h1 class="text-2xl md:text-3xl align-left w-full font-serif text-green-600">Income by Tag</h1>
<div class="flex w-full justify-center items-stretch relative gap-1 flex-wrap">
<div class="w-64 shrink-0 flex items-center">
<canvas id="income-tag-pie-chart-canvas" hx-preserve></canvas>
</div>
<div class="max-w-[32rem] max-h-64 grow overflow-y-auto">
<div class="flex flex-col" id="income-tag-breakdown"></div>
</div>
{% include "shared/spinner.jinja" %}
</div>
<h1 class="text-2xl md:text-3xl align-left w-full font-serif text-green-600">Expense</h1>
<div class="flex w-full justify-center items-stretch relative gap-1 flex-wrap">
<div class="w-64 shrink-0 flex items-center">
Expand All @@ -29,6 +39,16 @@
</div>
{% include "shared/spinner.jinja" %}
</div>
<h1 class="text-2xl md:text-3xl align-left w-full font-serif text-green-600">Expense by Tag</h1>
<div class="flex w-full justify-center items-stretch relative gap-1 flex-wrap">
<div class="w-64 shrink-0 flex items-center">
<canvas id="expense-tag-pie-chart-canvas" hx-preserve></canvas>
</div>
<div class="max-w-[32rem] max-h-64 grow overflow-y-auto">
<div class="flex flex-col" id="expense-tag-breakdown"></div>
</div>
{% include "shared/spinner.jinja" %}
</div>
{% include "cash-flow/chart-data.jinja" %}
<div class="w-full"
hx-target="#txn-table"
Expand Down
19 changes: 19 additions & 0 deletions tests/controllers/test_cash_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def test_txns(self) -> None:
t_split_0 = d["t_split_0"]
t_split_1 = d["t_split_1"]
cat_0_emoji = d["cat_0_emoji"]
tag_1 = d["tag_1"]

endpoint = "cash_flow.txns"
result, _ = self.web_get(
Expand All @@ -62,6 +63,14 @@ def test_txns(self) -> None:
self.assertRegex(result, r"<div .*>\$100.00</div>")
self.assertRegex(result, rf'hx-get="/h/transactions/t/{t_split_0}"')
self.assertNotRegex(result, rf'hx-get="/h/transactions/t/{t_split_1}"')
m = re.search(
r'<script>cashFlow\.update\(.*"expense_tagged": \[([^\]]+)\].*\)</script>',
result,
)
self.assertIsNotNone(m)
tags = m[1] if m else ""
self.assertIn(tag_1, tags)
self.assertIn("-10.00", tags)

result, _ = self.web_get(
(endpoint, {"period": "all"}),
Expand Down Expand Up @@ -114,6 +123,7 @@ def test_txns(self) -> None:
# Reverse categories for LUT
categories = {v: k for k, v in categories.items()}

tag_2 = self.random_string()
txn = Transaction(
account_id=acct_id,
date=today,
Expand All @@ -125,6 +135,7 @@ def test_txns(self) -> None:
parent=txn,
payee=self.random_string(),
category_id=categories["Groceries"],
tag=tag_2,
)
s.add_all((txn, t_split))

Expand All @@ -134,6 +145,14 @@ def test_txns(self) -> None:
self.assertIn("Interest", result)
self.assertIn("Groceries", result)
self.assertNotIn("Uncategorized", result)
m = re.search(
r'<script>cashFlow\.update\(.*"income_tagged": \[([^\]]+)\].*\)</script>',
result,
)
self.assertIsNotNone(m)
tags = m[1] if m else ""
self.assertIn(tag_2, tags)
self.assertIn("100.00", tags)

result, _ = self.web_get(
(endpoint, {"period": "1-year"}),
Expand Down

0 comments on commit a658d65

Please sign in to comment.