From a658d65f0feb1e80c0546a20a230b64561f5f148 Mon Sep 17 00:00:00 2001 From: Bradley Davis Date: Mon, 20 Jan 2025 13:46:58 -0800 Subject: [PATCH] Feature/cash flow tags (#258) * Added tag breakdown to cash flow page * Updated tests --- nummus/controllers/cash_flow.py | 53 +++++++++++++- nummus/static/src/cash-flow.js | 72 +++++++++++++++++++ .../templates/cash-flow/index-content.jinja | 20 ++++++ tests/controllers/test_cash_flow.py | 19 +++++ 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/nummus/controllers/cash_flow.py b/nummus/controllers/cash_flow.py index d96b788..4094fcc 100644 --- a/nummus/controllers/cash_flow.py +++ b/nummus/controllers/cash_flow.py @@ -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] = [] @@ -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 @@ -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, diff --git a/nummus/static/src/cash-flow.js b/nummus/static/src/cash-flow.js index d9ac964..6cdc4b5 100644 --- a/nummus/static/src/cash-flow.js +++ b/nummus/static/src/cash-flow.js @@ -5,6 +5,8 @@ const cashFlow = { chartExpense: null, chartPieIncome: null, chartPieExpense: null, + chartPieIncomeTag: null, + chartPieExpenseTag: null, /** * Create Cash Flow Chart * @@ -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) => { @@ -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(); @@ -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'); @@ -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; }, /** diff --git a/nummus/templates/cash-flow/index-content.jinja b/nummus/templates/cash-flow/index-content.jinja index cccf3b0..30edc7d 100644 --- a/nummus/templates/cash-flow/index-content.jinja +++ b/nummus/templates/cash-flow/index-content.jinja @@ -19,6 +19,16 @@ {% include "shared/spinner.jinja" %} +

Income by Tag

+
+
+ +
+
+
+
+ {% include "shared/spinner.jinja" %} +

Expense

@@ -29,6 +39,16 @@
{% include "shared/spinner.jinja" %}
+

Expense by Tag

+
+
+ +
+
+
+
+ {% include "shared/spinner.jinja" %} +
{% include "cash-flow/chart-data.jinja" %}
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( @@ -62,6 +63,14 @@ def test_txns(self) -> None: self.assertRegex(result, r"
\$100.00
") 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'', + 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"}), @@ -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, @@ -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)) @@ -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'', + 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"}),