From a3a8791e9611cf7981cb8e7e45566b8d84a277f1 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 9 Feb 2025 23:00:33 -0300 Subject: [PATCH 1/5] feat(insights): create app --- app/apps/insights/__init__.py | 0 app/apps/insights/admin.py | 3 +++ app/apps/insights/apps.py | 6 ++++++ app/apps/insights/migrations/__init__.py | 0 app/apps/insights/models.py | 3 +++ app/apps/insights/tests.py | 3 +++ app/apps/insights/views.py | 3 +++ 7 files changed, 18 insertions(+) create mode 100644 app/apps/insights/__init__.py create mode 100644 app/apps/insights/admin.py create mode 100644 app/apps/insights/apps.py create mode 100644 app/apps/insights/migrations/__init__.py create mode 100644 app/apps/insights/models.py create mode 100644 app/apps/insights/tests.py create mode 100644 app/apps/insights/views.py diff --git a/app/apps/insights/__init__.py b/app/apps/insights/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/insights/admin.py b/app/apps/insights/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/apps/insights/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/apps/insights/apps.py b/app/apps/insights/apps.py new file mode 100644 index 0000000..06aeec7 --- /dev/null +++ b/app/apps/insights/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InsightsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.insights" diff --git a/app/apps/insights/migrations/__init__.py b/app/apps/insights/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/insights/models.py b/app/apps/insights/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/app/apps/insights/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/app/apps/insights/tests.py b/app/apps/insights/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/apps/insights/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/app/apps/insights/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 9c55dac866ad46552eb6c57633be3b920341b19d Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 11 Feb 2025 00:37:30 -0300 Subject: [PATCH 2/5] feat(insights): sankey diagram (WIP) --- app/WYGIWYH/urls.py | 1 + app/apps/insights/urls.py | 7 + app/apps/insights/utils/__init__.py | 0 app/apps/insights/utils/sankey.py | 102 +++++++++++++ app/apps/insights/views.py | 16 +- app/templates/insights/fragments/sankey.html | 149 +++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 app/apps/insights/urls.py create mode 100644 app/apps/insights/utils/__init__.py create mode 100644 app/apps/insights/utils/sankey.py create mode 100644 app/templates/insights/fragments/sankey.html diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index d2d2d5d..bd17bac 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -49,4 +49,5 @@ urlpatterns = [ path("", include("apps.dca.urls")), path("", include("apps.mini_tools.urls")), path("", include("apps.import_app.urls")), + path("", include("apps.insights.urls")), ] diff --git a/app/apps/insights/urls.py b/app/apps/insights/urls.py new file mode 100644 index 0000000..b8f5c97 --- /dev/null +++ b/app/apps/insights/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("insights/sankey/", views.sankey, name="sankey"), +] diff --git a/app/apps/insights/utils/__init__.py b/app/apps/insights/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/insights/utils/sankey.py b/app/apps/insights/utils/sankey.py new file mode 100644 index 0000000..1d56310 --- /dev/null +++ b/app/apps/insights/utils/sankey.py @@ -0,0 +1,102 @@ +from django.db.models import Sum +from django.utils.translation import gettext_lazy as _ +from decimal import Decimal +from typing import Dict, List, TypedDict + + +class SankeyNode(TypedDict): + name: str + + +class SankeyFlow(TypedDict): + from_node: str + to_node: str + flow: float + currency: Dict + original_amount: float + percentage: float + + +def generate_sankey_data(transactions_queryset): + """ + Generates Sankey diagram data from transaction queryset. + Uses a 1-5 scale for flows based on percentages. + """ + nodes: Dict[str, SankeyNode] = {} + flows: List[SankeyFlow] = [] + + # Aggregate transactions + income_data = {} # {(category, currency, account) -> amount} + expense_data = {} # {(category, currency, account) -> amount} + total_amount = Decimal("0") + + for transaction in transactions_queryset: + currency = transaction.account.currency + account = transaction.account + category = transaction.category or _("Uncategorized") + + key = (category, currency, account) + + if transaction.type == "IN": + income_data[key] = income_data.get(key, Decimal("0")) + transaction.amount + else: + expense_data[key] = expense_data.get(key, Decimal("0")) + transaction.amount + + total_amount += transaction.amount + + # Function to add flow + def add_flow(from_node, to_node, amount, currency): + percentage = (amount / total_amount) * 100 if total_amount else 0 + scaled_flow = 1 + min(percentage / 5, 2) # Scale 1-5, capping at 100% + flows.append( + { + "from_node": from_node, + "to_node": to_node, + "flow": float(scaled_flow), + "currency": { + "code": currency.code, + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimal_places": currency.decimal_places, + }, + "original_amount": float(amount), + "percentage": float(percentage), + } + ) + nodes[from_node] = {"name": from_node} + nodes[to_node] = {"name": to_node} + + # Process income + for (category, currency, account), amount in income_data.items(): + category_name = f"{category} ({currency.code})" + account_name = f"{account.name} ({currency.code})" + add_flow(category_name, account_name, amount, currency) + + # Process expenses + for (category, currency, account), amount in expense_data.items(): + category_name = f"{category} ({currency.code})" + account_name = f"{account.name} ({currency.code})" + add_flow(account_name, category_name, amount, currency) + + # Calculate and add savings flows + savings_data = {} # {(account, currency) -> amount} + + for (category, currency, account), amount in income_data.items(): + key = (account, currency) + savings_data[key] = savings_data.get(key, Decimal("0")) + amount + + for (category, currency, account), amount in expense_data.items(): + key = (account, currency) + savings_data[key] = savings_data.get(key, Decimal("0")) - amount + + for (account, currency), amount in savings_data.items(): + if amount > 0: + account_name = f"{account.name} ({currency.code})" + savings_name = f"{_('Savings')} ({currency.code})" + add_flow(account_name, savings_name, amount, currency) + + return { + "nodes": list(nodes.values()), + "flows": flows, + "total_amount": float(total_amount), + } diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index 91ea44a..2bb4cfa 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -1,3 +1,17 @@ from django.shortcuts import render -# Create your views here. +from apps.transactions.models import Transaction +from apps.insights.utils.sankey import generate_sankey_data + + +def sankey(request): + # Get filtered transactions + transactions = Transaction.objects.filter(date__year=2025) + + # Generate Sankey data + sankey_data = generate_sankey_data(transactions) + print(sankey_data) + + return render( + request, "insights/fragments/sankey.html", {"sankey_data": sankey_data} + ) diff --git a/app/templates/insights/fragments/sankey.html b/app/templates/insights/fragments/sankey.html new file mode 100644 index 0000000..d71b8a3 --- /dev/null +++ b/app/templates/insights/fragments/sankey.html @@ -0,0 +1,149 @@ +{% extends "layouts/base.html" %} + +{% block content %} + + + +{% endblock %} From d0f2742637da2a6963175f10a225d05603542365 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 11 Feb 2025 00:37:48 -0300 Subject: [PATCH 3/5] chore(frontend): install chartjs-chart-sankey --- frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 1 + frontend/src/application/charts.js | 3 +++ 3 files changed, 14 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7beead9..45e0e3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "babel-loader": "^8.2.3", "bootstrap": "^5.3.3", "chart.js": "^4.4.6", + "chartjs-chart-sankey": "^0.14.0", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.20.3", @@ -3235,6 +3236,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-chart-sankey": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/chartjs-chart-sankey/-/chartjs-chart-sankey-0.14.0.tgz", + "integrity": "sha512-MrU3lE73TE9kALy4MjWFlfcwf4R1EN/DBvhHxmv9n4AHap//JLKjlJTLIZwHsUjDsYo0B8PuMkrJODwfirEZUA==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.3.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee3e8a1..480488e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "babel-loader": "^8.2.3", "bootstrap": "^5.3.3", "chart.js": "^4.4.6", + "chartjs-chart-sankey": "^0.14.0", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.20.3", diff --git a/frontend/src/application/charts.js b/frontend/src/application/charts.js index 7ccc9d9..8ee84c5 100644 --- a/frontend/src/application/charts.js +++ b/frontend/src/application/charts.js @@ -1,2 +1,5 @@ import Chart from 'chart.js/auto'; +import {SankeyController, Flow} from 'chartjs-chart-sankey'; + +Chart.register(SankeyController, Flow); window.Chart = Chart; From 28b12faaf0f6e8d21482301f1f14a7d61915f1f4 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 11 Feb 2025 00:40:37 -0300 Subject: [PATCH 4/5] fix(insights): sankey diagram inconsistent sizing --- app/apps/insights/utils/sankey.py | 2 +- app/templates/insights/fragments/sankey.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/apps/insights/utils/sankey.py b/app/apps/insights/utils/sankey.py index 1d56310..05e1fc2 100644 --- a/app/apps/insights/utils/sankey.py +++ b/app/apps/insights/utils/sankey.py @@ -47,7 +47,7 @@ def generate_sankey_data(transactions_queryset): # Function to add flow def add_flow(from_node, to_node, amount, currency): percentage = (amount / total_amount) * 100 if total_amount else 0 - scaled_flow = 1 + min(percentage / 5, 2) # Scale 1-5, capping at 100% + scaled_flow = 1 + min(percentage / 20, 4) # Scale 1-5, capping at 100% flows.append( { "from_node": from_node, diff --git a/app/templates/insights/fragments/sankey.html b/app/templates/insights/fragments/sankey.html index d71b8a3..1b7d22c 100644 --- a/app/templates/insights/fragments/sankey.html +++ b/app/templates/insights/fragments/sankey.html @@ -1,7 +1,7 @@ {% extends "layouts/base.html" %} {% block content %} - +