From 3a5e688a9011579c5a89a5fd39a51f649ad19654 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 12 Sep 2026 14:31:12 -0300 Subject: [PATCH 1/2] feat(insights): add tags and entities overview --- app/apps/insights/urls.py | 10 + app/apps/insights/utils/category_overview.py | 504 ---------------- app/apps/insights/utils/overview.py | 236 ++++++++ app/apps/insights/views.py | 138 +++-- .../fragments/category_overview/index.html | 556 ------------------ .../insights/fragments/overview/_row.html | 56 ++ .../insights/fragments/overview/index.html | 283 +++++++++ app/templates/insights/pages/index.html | 10 + 8 files changed, 689 insertions(+), 1104 deletions(-) delete mode 100644 app/apps/insights/utils/category_overview.py create mode 100644 app/apps/insights/utils/overview.py delete mode 100644 app/templates/insights/fragments/category_overview/index.html create mode 100644 app/templates/insights/fragments/overview/_row.html create mode 100644 app/templates/insights/fragments/overview/index.html diff --git a/app/apps/insights/urls.py b/app/apps/insights/urls.py index 17ae3a98..f50a73a4 100644 --- a/app/apps/insights/urls.py +++ b/app/apps/insights/urls.py @@ -34,6 +34,16 @@ urlpatterns = [ views.category_overview, name="category_overview", ), + path( + "insights/tag-overview/", + views.tag_overview, + name="tag_overview", + ), + path( + "insights/entity-overview/", + views.entity_overview, + name="entity_overview", + ), path( "insights/late-transactions/", views.late_transactions, diff --git a/app/apps/insights/utils/category_overview.py b/app/apps/insights/utils/category_overview.py deleted file mode 100644 index 4025127f..00000000 --- a/app/apps/insights/utils/category_overview.py +++ /dev/null @@ -1,504 +0,0 @@ -from decimal import Decimal - -from django.db import models -from django.db.models import Sum, Case, When, Value, DecimalField -from django.db.models.functions import Coalesce - -from apps.transactions.models import Transaction -from apps.currencies.models import Currency -from apps.currencies.utils.convert import convert - - -def get_categories_totals( - transactions_queryset, ignore_empty=False, show_entities=False -): - # Step 1: Aggregate transaction data by category and currency. - # This query calculates the total current and projected income/expense for each - # category by grouping transactions and summing up their amounts based on their - # type (income/expense) and payment status (paid/unpaid). - category_currency_metrics = ( - transactions_queryset.values( - "category", - "category__name", - "account__currency", - "account__currency__code", - "account__currency__name", - "account__currency__decimal_places", - "account__currency__prefix", - "account__currency__suffix", - "account__currency__exchange_currency", - ) - .annotate( - expense_current=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.EXPENSE, is_paid=True, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - expense_projected=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.EXPENSE, is_paid=False, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_current=Coalesce( - Sum( - Case( - When(type=Transaction.Type.INCOME, is_paid=True, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_projected=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.INCOME, is_paid=False, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - ) - .order_by("category__name") - ) - - # Step 2: Aggregate transaction data by tag, category, and currency. - # This is similar to the category metrics but adds tags to the grouping, - # allowing for a breakdown of totals by tag within each category. It also - # handles untagged transactions, where the 'tags' field is None. - tag_metrics = transactions_queryset.values( - "category", - "tags", - "tags__name", - "account__currency", - "account__currency__code", - "account__currency__name", - "account__currency__decimal_places", - "account__currency__prefix", - "account__currency__suffix", - "account__currency__exchange_currency", - ).annotate( - expense_current=Coalesce( - Sum( - Case( - When(type=Transaction.Type.EXPENSE, is_paid=True, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - expense_projected=Coalesce( - Sum( - Case( - When(type=Transaction.Type.EXPENSE, is_paid=False, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_current=Coalesce( - Sum( - Case( - When(type=Transaction.Type.INCOME, is_paid=True, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_projected=Coalesce( - Sum( - Case( - When(type=Transaction.Type.INCOME, is_paid=False, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - ) - - # Step 3: Initialize the main dictionary to structure the final results. - # The data will be organized hierarchically: category -> currency -> tags -> entities. - result = {} - - # Step 4: Process the aggregated category metrics to build the initial result structure. - # This loop iterates through each category's metrics and populates the `result` dict. - for metric in category_currency_metrics: - # Skip empty categories if ignore_empty is True - if ignore_empty and all( - metric[field] == Decimal("0") - for field in [ - "expense_current", - "expense_projected", - "income_current", - "income_projected", - ] - ): - continue - - # Calculate derived totals - total_current = metric["income_current"] - metric["expense_current"] - total_projected = metric["income_projected"] - metric["expense_projected"] - total_income = metric["income_current"] + metric["income_projected"] - total_expense = metric["expense_current"] + metric["expense_projected"] - total_final = total_current + total_projected - - category_id = metric["category"] - currency_id = metric["account__currency"] - - if category_id not in result: - result[category_id] = { - "name": metric["category__name"], - "currencies": {}, - "tags": {}, # Add tags container - } - - # Add currency data - currency_data = { - "currency": { - "code": metric["account__currency__code"], - "name": metric["account__currency__name"], - "decimal_places": metric["account__currency__decimal_places"], - "prefix": metric["account__currency__prefix"], - "suffix": metric["account__currency__suffix"], - }, - "expense_current": metric["expense_current"], - "expense_projected": metric["expense_projected"], - "total_expense": total_expense, - "income_current": metric["income_current"], - "income_projected": metric["income_projected"], - "total_income": total_income, - "total_current": total_current, - "total_projected": total_projected, - "total_final": total_final, - } - - # Step 4a: Handle currency conversion for category totals if an exchange currency is defined. - if metric["account__currency__exchange_currency"]: - from_currency = Currency.objects.get(id=currency_id) - exchange_currency = Currency.objects.get( - id=metric["account__currency__exchange_currency"] - ) - - exchanged = {} - for field in [ - "expense_current", - "expense_projected", - "income_current", - "income_projected", - "total_income", - "total_expense", - "total_current", - "total_projected", - "total_final", - ]: - amount, prefix, suffix, decimal_places = convert( - amount=currency_data[field], - from_currency=from_currency, - to_currency=exchange_currency, - ) - if amount is not None: - exchanged[field] = amount - if "currency" not in exchanged: - exchanged["currency"] = { - "prefix": prefix, - "suffix": suffix, - "decimal_places": decimal_places, - "code": exchange_currency.code, - "name": exchange_currency.name, - } - if exchanged: - currency_data["exchanged"] = exchanged - - result[category_id]["currencies"][currency_id] = currency_data - - # Step 5: Process the aggregated tag metrics and integrate them into the result structure. - for tag_metric in tag_metrics: - category_id = tag_metric["category"] - tag_id = tag_metric["tags"] # Will be None for untagged transactions - - if category_id in result: - # Initialize the tag container if not exists - if "tags" not in result[category_id]: - result[category_id]["tags"] = {} - - # Determine if this is a tagged or untagged transaction - tag_key = tag_id if tag_id is not None else "untagged" - tag_name = tag_metric["tags__name"] if tag_id is not None else None - - if tag_key not in result[category_id]["tags"]: - result[category_id]["tags"][tag_key] = { - "name": tag_name, - "currencies": {}, - "entities": {}, - } - - currency_id = tag_metric["account__currency"] - - # Calculate tag totals - tag_total_current = ( - tag_metric["income_current"] - tag_metric["expense_current"] - ) - tag_total_projected = ( - tag_metric["income_projected"] - tag_metric["expense_projected"] - ) - tag_total_income = ( - tag_metric["income_current"] + tag_metric["income_projected"] - ) - tag_total_expense = ( - tag_metric["expense_current"] + tag_metric["expense_projected"] - ) - tag_total_final = tag_total_current + tag_total_projected - - tag_currency_data = { - "currency": { - "code": tag_metric["account__currency__code"], - "name": tag_metric["account__currency__name"], - "decimal_places": tag_metric["account__currency__decimal_places"], - "prefix": tag_metric["account__currency__prefix"], - "suffix": tag_metric["account__currency__suffix"], - }, - "expense_current": tag_metric["expense_current"], - "expense_projected": tag_metric["expense_projected"], - "total_expense": tag_total_expense, - "income_current": tag_metric["income_current"], - "income_projected": tag_metric["income_projected"], - "total_income": tag_total_income, - "total_current": tag_total_current, - "total_projected": tag_total_projected, - "total_final": tag_total_final, - } - - # Step 5a: Handle currency conversion for tag totals. - if tag_metric["account__currency__exchange_currency"]: - from_currency = Currency.objects.get(id=currency_id) - exchange_currency = Currency.objects.get( - id=tag_metric["account__currency__exchange_currency"] - ) - - exchanged = {} - for field in [ - "expense_current", - "expense_projected", - "income_current", - "income_projected", - "total_income", - "total_expense", - "total_current", - "total_projected", - "total_final", - ]: - amount, prefix, suffix, decimal_places = convert( - amount=tag_currency_data[field], - from_currency=from_currency, - to_currency=exchange_currency, - ) - if amount is not None: - exchanged[field] = amount - if "currency" not in exchanged: - exchanged["currency"] = { - "prefix": prefix, - "suffix": suffix, - "decimal_places": decimal_places, - "code": exchange_currency.code, - "name": exchange_currency.name, - } - if exchanged: - tag_currency_data["exchanged"] = exchanged - - result[category_id]["tags"][tag_key]["currencies"][ - currency_id - ] = tag_currency_data - - # Step 6: If requested, aggregate and process entity-level data. - if show_entities: - entity_metrics = transactions_queryset.values( - "category", - "tags", - "entities", - "entities__name", - "account__currency", - "account__currency__code", - "account__currency__name", - "account__currency__decimal_places", - "account__currency__prefix", - "account__currency__suffix", - "account__currency__exchange_currency", - ).annotate( - expense_current=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.EXPENSE, is_paid=True, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - expense_projected=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.EXPENSE, is_paid=False, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_current=Coalesce( - Sum( - Case( - When(type=Transaction.Type.INCOME, is_paid=True, then="amount"), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - income_projected=Coalesce( - Sum( - Case( - When( - type=Transaction.Type.INCOME, is_paid=False, then="amount" - ), - default=Value(0), - output_field=models.DecimalField(), - ) - ), - Decimal("0"), - ), - ) - - for entity_metric in entity_metrics: - category_id = entity_metric["category"] - tag_id = entity_metric["tags"] - entity_id = entity_metric["entities"] - - if category_id in result: - tag_key = tag_id if tag_id is not None else "untagged" - if tag_key in result[category_id]["tags"]: - entity_key = entity_id if entity_id is not None else "no_entity" - entity_name = ( - entity_metric["entities__name"] - if entity_id is not None - else None - ) - - if "entities" not in result[category_id]["tags"][tag_key]: - result[category_id]["tags"][tag_key]["entities"] = {} - - if ( - entity_key - not in result[category_id]["tags"][tag_key]["entities"] - ): - result[category_id]["tags"][tag_key]["entities"][entity_key] = { - "name": entity_name, - "currencies": {}, - } - - currency_id = entity_metric["account__currency"] - - entity_total_current = ( - entity_metric["income_current"] - - entity_metric["expense_current"] - ) - entity_total_projected = ( - entity_metric["income_projected"] - - entity_metric["expense_projected"] - ) - entity_total_income = ( - entity_metric["income_current"] - + entity_metric["income_projected"] - ) - entity_total_expense = ( - entity_metric["expense_current"] - + entity_metric["expense_projected"] - ) - entity_total_final = entity_total_current + entity_total_projected - - entity_currency_data = { - "currency": { - "code": entity_metric["account__currency__code"], - "name": entity_metric["account__currency__name"], - "decimal_places": entity_metric[ - "account__currency__decimal_places" - ], - "prefix": entity_metric["account__currency__prefix"], - "suffix": entity_metric["account__currency__suffix"], - }, - "expense_current": entity_metric["expense_current"], - "expense_projected": entity_metric["expense_projected"], - "total_expense": entity_total_expense, - "income_current": entity_metric["income_current"], - "income_projected": entity_metric["income_projected"], - "total_income": entity_total_income, - "total_current": entity_total_current, - "total_projected": entity_total_projected, - "total_final": entity_total_final, - } - - if entity_metric["account__currency__exchange_currency"]: - from_currency = Currency.objects.get(id=currency_id) - exchange_currency = Currency.objects.get( - id=entity_metric["account__currency__exchange_currency"] - ) - - exchanged = {} - for field in [ - "expense_current", - "expense_projected", - "income_current", - "income_projected", - "total_income", - "total_expense", - "total_current", - "total_projected", - "total_final", - ]: - amount, prefix, suffix, decimal_places = convert( - amount=entity_currency_data[field], - from_currency=from_currency, - to_currency=exchange_currency, - ) - if amount is not None: - exchanged[field] = amount - if "currency" not in exchanged: - exchanged["currency"] = { - "prefix": prefix, - "suffix": suffix, - "decimal_places": decimal_places, - "code": exchange_currency.code, - "name": exchange_currency.name, - } - if exchanged: - entity_currency_data["exchanged"] = exchanged - - result[category_id]["tags"][tag_key]["entities"][entity_key][ - "currencies" - ][currency_id] = entity_currency_data - - return result diff --git a/app/apps/insights/utils/overview.py b/app/apps/insights/utils/overview.py new file mode 100644 index 00000000..cfa4d168 --- /dev/null +++ b/app/apps/insights/utils/overview.py @@ -0,0 +1,236 @@ +from decimal import Decimal + +from django.db import models +from django.db.models import Sum, Case, When, Value +from django.db.models.functions import Coalesce + +from apps.currencies.models import Currency +from apps.currencies.utils.convert import convert +from apps.transactions.models import Transaction + +# Grouping levels an overview can be built from. Any ordering of these keys is a +# valid hierarchy, which is what makes the categories, tags and entities +# overviews the same view with a different level order. +LEVELS = { + "categories": {"field": "category", "name": "category__name"}, + "tags": {"field": "tags", "name": "tags__name"}, + "entities": {"field": "entities", "name": "entities__name"}, +} + +CURRENCY_FIELDS = ( + "account__currency", + "account__currency__code", + "account__currency__name", + "account__currency__decimal_places", + "account__currency__prefix", + "account__currency__suffix", + "account__currency__exchange_currency", +) + +# Aggregated amounts, plus the totals derived from them. Every one of these is +# converted when the account's currency has an exchange currency. +AGGREGATED_FIELDS = ( + "expense_current", + "expense_projected", + "income_current", + "income_projected", +) +TOTAL_FIELDS = ( + "total_income", + "total_expense", + "total_current", + "total_projected", + "total_final", +) + +# Fields picked for display, per "showing" mode. Doing it here keeps the +# templates down to one branch per cell instead of one per mode. +SHOWN_FIELDS = { + "current": ("income_current", "expense_current", "total_current"), + "projected": ("income_projected", "expense_projected", "total_projected"), + "final": ("total_income", "total_expense", "total_final"), +} + +# Key used for rows where the grouping field is null (untagged transactions, for +# instance). None is already a meaningful key for the top level, so children use +# a sentinel string to keep the dictionaries JSON serializable. +NO_VALUE = "none" + + +def _child_key(value): + return NO_VALUE if value is None else value + + +def _sum_of(transaction_type, is_paid): + return Coalesce( + Sum( + Case( + When(type=transaction_type, is_paid=is_paid, then="amount"), + default=Value(0), + output_field=models.DecimalField(), + ) + ), + Decimal("0"), + ) + + +def _aggregate(transactions_queryset, group_fields, name_field): + """Sum income/expense per currency, grouped by the given fields.""" + return ( + transactions_queryset.values(*group_fields, name_field, *CURRENCY_FIELDS) + .annotate( + expense_current=_sum_of(Transaction.Type.EXPENSE, is_paid=True), + expense_projected=_sum_of(Transaction.Type.EXPENSE, is_paid=False), + income_current=_sum_of(Transaction.Type.INCOME, is_paid=True), + income_projected=_sum_of(Transaction.Type.INCOME, is_paid=False), + ) + .order_by(name_field) + ) + + +def _build_currency_data(metric, showing): + """Turn one aggregated row into the currency payload the templates read.""" + total_current = metric["income_current"] - metric["expense_current"] + total_projected = metric["income_projected"] - metric["expense_projected"] + + currency_data = { + "currency": { + "code": metric["account__currency__code"], + "name": metric["account__currency__name"], + "decimal_places": metric["account__currency__decimal_places"], + "prefix": metric["account__currency__prefix"], + "suffix": metric["account__currency__suffix"], + }, + "expense_current": metric["expense_current"], + "expense_projected": metric["expense_projected"], + "total_expense": metric["expense_current"] + metric["expense_projected"], + "income_current": metric["income_current"], + "income_projected": metric["income_projected"], + "total_income": metric["income_current"] + metric["income_projected"], + "total_current": total_current, + "total_projected": total_projected, + "total_final": total_current + total_projected, + } + + income, expense, total = SHOWN_FIELDS.get(showing, SHOWN_FIELDS["final"]) + currency_data["shown"] = { + "income": currency_data[income], + "expense": currency_data[expense], + "total": currency_data[total], + } + + if metric["account__currency__exchange_currency"]: + from_currency = Currency.objects.get(id=metric["account__currency"]) + exchange_currency = Currency.objects.get( + id=metric["account__currency__exchange_currency"] + ) + + exchanged = {} + for field in AGGREGATED_FIELDS + TOTAL_FIELDS: + amount, prefix, suffix, decimal_places = convert( + amount=currency_data[field], + from_currency=from_currency, + to_currency=exchange_currency, + ) + if amount is not None: + exchanged[field] = amount + if "currency" not in exchanged: + exchanged["currency"] = { + "prefix": prefix, + "suffix": suffix, + "decimal_places": decimal_places, + "code": exchange_currency.code, + "name": exchange_currency.name, + } + if exchanged: + currency_data["exchanged"] = exchanged + + return currency_data + + +def get_grouped_totals( + transactions_queryset, levels, showing="final", ignore_empty=False, depth=1 +): + """ + Build a nested income/expense breakdown of ``transactions_queryset``. + + ``showing`` picks which set of amounts lands in each row's ``shown`` key: + ``current`` (paid only), ``projected`` (unpaid only) or ``final`` (both). + + ``levels`` is an ordered sequence of keys from ``LEVELS`` describing the + hierarchy (e.g. ``("categories", "tags", "entities")``); ``depth`` says how + many of those levels to actually aggregate, so the caller only pays for the + rows it is going to show. + + Returns a dict keyed by the first level's id:: + + { + id: { + "name": str | None, + "search_path": str, + "currencies": {currency_id: {...}}, + "children": {id: {"name": ..., "currencies": ..., "children": ...}}, + } + } + + A null grouping value keeps ``None`` as the key at the top level and uses + ``NO_VALUE`` further down; ``name`` is ``None`` in both cases so templates + can render the label that fits the level. + """ + levels = [level for level in levels if level in LEVELS][: max(depth, 1)] + if not levels: + return {} + + fields = [LEVELS[level]["field"] for level in levels] + names = [LEVELS[level]["name"] for level in levels] + + result = {} + + for metric in _aggregate(transactions_queryset, fields[:1], names[0]): + if ignore_empty and all( + metric[field] == Decimal("0") for field in AGGREGATED_FIELDS + ): + continue + + node = result.setdefault( + metric[fields[0]], + { + "name": metric[names[0]], + "search_path": f"{len(result)}/", + "currencies": {}, + "children": {}, + }, + ) + node["currencies"][metric["account__currency"]] = _build_currency_data( + metric, showing + ) + + # Each extra level is aggregated on its own and grafted onto the node its + # parent ids point at, so a child of a skipped (empty) parent is dropped. + for index in range(1, len(levels)): + for metric in _aggregate( + transactions_queryset, fields[: index + 1], names[index] + ): + node = result.get(metric[fields[0]]) + for parent_field in fields[1:index]: + if node is None: + break + node = node["children"].get(_child_key(metric[parent_field])) + + if node is None: + continue + + child = node["children"].setdefault( + _child_key(metric[fields[index]]), + { + "name": metric[names[index]], + "search_path": f"{node['search_path']}{len(node['children'])}/", + "currencies": {}, + "children": {}, + }, + ) + child["currencies"][metric["account__currency"]] = _build_currency_data( + metric, showing + ) + + return result diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index ec925cf2..ab5d86ec 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -4,7 +4,9 @@ from dateutil.relativedelta import relativedelta from django.contrib.auth.decorators import login_required from django.db.models import Sum from django.shortcuts import render +from django.urls import reverse from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx @@ -20,7 +22,7 @@ from apps.insights.utils.category_explorer import ( get_category_sums_by_account, get_category_sums_by_currency, ) -from apps.insights.utils.category_overview import get_categories_totals +from apps.insights.utils.overview import get_grouped_totals from apps.insights.utils.sankey import ( generate_sankey_data_by_account, generate_sankey_data_by_currency, @@ -31,6 +33,81 @@ from apps.insights.utils.month_by_month import get_month_by_month_data from apps.transactions.models import TransactionCategory, Transaction from apps.transactions.utils.calculations import calculate_currency_totals +# Labels for the grouping levels an overview can be built from. The overviews +# only differ by which level sits at the top, so everything user facing lives +# here instead of in three near identical views and templates. +OVERVIEW_LEVELS = { + "categories": { + "label": _("Categories"), + "singular": _("Category"), + "empty": _("Uncategorized"), + "empty_message": _("No categories"), + "icon": "fa-solid fa-icons", + }, + "tags": { + "label": _("Tags"), + "singular": _("Tag"), + "empty": _("Untagged"), + "empty_message": _("No tags"), + "icon": "fa-solid fa-hashtag", + }, + "entities": { + "label": _("Entities"), + "singular": _("Entity"), + "empty": _("No entity"), + "empty_message": _("No entities"), + "icon": "fa-solid fa-user-group", + }, +} + + +def _render_overview(request, levels, url_name): + """Render the overview table/chart for ``levels``, top level first.""" + session_prefix = f"insights_{levels[0]}_overview" + + def setting(name, default, cast=None): + key = f"{session_prefix}_{name}" + if name in request.GET: + value = cast(request.GET[name]) if cast else request.GET[name] + request.session[key] = value + return value + return request.session.get(key, default) + + view_type = setting("view_type", "table") + showing = setting("showing", "final") + show_level_2 = setting("show_level_2", True, cast=lambda value: value == "on") + show_level_3 = setting("show_level_3", False, cast=lambda value: value == "on") + + if show_level_2: + depth = 3 if show_level_3 else 2 + else: + depth = 1 + + total_table = get_grouped_totals( + transactions_queryset=get_transactions(request, include_silent=True), + levels=levels, + showing=showing, + ignore_empty=False, + depth=depth, + ) + + return render( + request, + "insights/fragments/overview/index.html", + { + "total_table": total_table, + "refresh_url": reverse(url_name), + "view_type": view_type, + "showing": showing, + "show_level_2": show_level_2, + "show_level_3": show_level_3, + "level_1": OVERVIEW_LEVELS[levels[0]], + "level_2": OVERVIEW_LEVELS[levels[1]], + "level_3": OVERVIEW_LEVELS[levels[2]], + "empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"], + }, + ) + @login_required @require_http_methods(["GET"]) @@ -174,51 +251,24 @@ def category_sum_by_currency(request): @login_required @require_http_methods(["GET"]) def category_overview(request): - if "view_type" in request.GET: - view_type = request.GET["view_type"] - request.session["insights_category_explorer_view_type"] = view_type - else: - view_type = request.session.get("insights_category_explorer_view_type", "table") - - if "show_tags" in request.GET: - show_tags = request.GET["show_tags"] == "on" - request.session["insights_category_explorer_show_tags"] = show_tags - else: - show_tags = request.session.get("insights_category_explorer_show_tags", True) - - if "show_entities" in request.GET: - show_entities = request.GET["show_entities"] == "on" - request.session["insights_category_explorer_show_entities"] = show_entities - else: - show_entities = request.session.get( - "insights_category_explorer_show_entities", False - ) - - if "showing" in request.GET: - showing = request.GET["showing"] - request.session["insights_category_explorer_showing"] = showing - else: - showing = request.session.get("insights_category_explorer_showing", "final") - - # Get filtered transactions - transactions = get_transactions(request, include_silent=True) - - total_table = get_categories_totals( - transactions_queryset=transactions, - ignore_empty=False, - show_entities=show_entities, + return _render_overview( + request, ("categories", "tags", "entities"), "category_overview" ) - return render( - request, - "insights/fragments/category_overview/index.html", - { - "total_table": total_table, - "view_type": view_type, - "show_tags": show_tags, - "show_entities": show_entities, - "showing": showing, - }, + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def tag_overview(request): + return _render_overview(request, ("tags", "categories", "entities"), "tag_overview") + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def entity_overview(request): + return _render_overview( + request, ("entities", "categories", "tags"), "entity_overview" ) diff --git a/app/templates/insights/fragments/category_overview/index.html b/app/templates/insights/fragments/category_overview/index.html deleted file mode 100644 index 0ef018bf..00000000 --- a/app/templates/insights/fragments/category_overview/index.html +++ /dev/null @@ -1,556 +0,0 @@ -{% load i18n %} - -
-
-
- - -
-
-
-
- {% if view_type == 'table' %} -
- -
-
- -
- {% endif %} -
-
- - - - - -
-
- {% if total_table %} - {% if view_type == "table" %} -
-
- -
- - - - - - - - - - - {% for category in total_table.values %} -{# Category row #} - - - - - - - - - {% if show_tags %} - {% for tag_id, tag in category.tags.items %} - {% if tag.name or not tag.name and category.tags.values|length > 1 %} - - - - - - -{# Entity rows #} - {% if show_entities %} - {% for entity_id, entity in tag.entities.items %} - {% if entity.name or not entity.name and tag.entities.values|length > 1 %} - - - - - - - {% endif %} - {% endfor %} - {% endif %} - {% endif %} - {% endfor %} - {% endif %} - {% endfor %} - -
{% trans 'Category' %}{% trans 'Income' %}{% trans 'Expense' %}{% trans 'Total' %}
{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %} - {% for currency in category.currencies.values %} - {% if showing == 'current' and currency.income_current != 0 %} - - {% elif showing == 'projected' and currency.income_projected != 0 %} - - {% elif showing == 'final' and currency.total_income != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in category.currencies.values %} - {% if showing == 'current' and currency.expense_current != 0 %} - - {% elif showing == 'projected' and currency.expense_projected != 0 %} - - {% elif showing == 'final' and currency.total_expense != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in category.currencies.values %} - {% if showing == 'current' and currency.total_current != 0 %} - - {% elif showing == 'projected' and currency.total_projected != 0 %} - - {% elif showing == 'final' and currency.total_final != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% if tag.name %}{{ tag.name }}{% else %}{% trans 'Untagged' %}{% endif %} - - {% for currency in tag.currencies.values %} - {% if showing == 'current' and currency.income_current != 0 %} - - {% elif showing == 'projected' and currency.income_projected != 0 %} - - {% elif showing == 'final' and currency.total_income != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in tag.currencies.values %} - {% if showing == 'current' and currency.expense_current != 0 %} - - {% elif showing == 'projected' and currency.expense_projected != 0 %} - - {% elif showing == 'final' and currency.total_expense != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in tag.currencies.values %} - {% if showing == 'current' and currency.total_current != 0 %} - - {% elif showing == 'projected' and currency.total_projected != 0 %} - - {% elif showing == 'final' and currency.total_final != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% if entity.name %}{{ entity.name }}{% else %}{% trans 'No entity' %}{% endif %} - - {% for currency in entity.currencies.values %} - {% if showing == 'current' and currency.income_current != 0 %} - - {% elif showing == 'projected' and currency.income_projected != 0 %} - - {% elif showing == 'final' and currency.total_income != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in entity.currencies.values %} - {% if showing == 'current' and currency.expense_current != 0 %} - - {% elif showing == 'projected' and currency.expense_projected != 0 %} - - {% elif showing == 'final' and currency.total_expense != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
- {% for currency in entity.currencies.values %} - {% if showing == 'current' and currency.total_current != 0 %} - - {% elif showing == 'projected' and currency.total_projected != 0 %} - - {% elif showing == 'final' and currency.total_final != 0 %} - - {% else %} -
-
- {% endif %} - {% endfor %} -
-
-
-
- - - {% elif view_type == "bars" %} -
-
-
- -
-
-
- - {{ total_table|json_script:"categoryOverviewData" }} - {{ showing|json_script:"showingString" }} - - - {% endif %} - {% else %} - - {% endif %} -
diff --git a/app/templates/insights/fragments/overview/_row.html b/app/templates/insights/fragments/overview/_row.html new file mode 100644 index 00000000..9cd493d9 --- /dev/null +++ b/app/templates/insights/fragments/overview/_row.html @@ -0,0 +1,56 @@ +{% load i18n %} +{% comment %} + One overview row. Expects: node, indent, icon, empty_label, row_class, + search_path and, for the top level, is_root. +{% endcomment %} + + {% if is_root %} + {% if node.name %}{{ node.name }}{% else %}{{ empty_label }}{% endif %} + {% else %} + + {% if node.name %}{{ node.name }}{% else %}{{ empty_label }}{% endif %} + + {% endif %} + + {% for currency in node.currencies.values %} + {% if currency.shown.income != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} + + + {% for currency in node.currencies.values %} + {% if currency.shown.expense != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} + + + {% for currency in node.currencies.values %} + {% if currency.shown.total != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} + + diff --git a/app/templates/insights/fragments/overview/index.html b/app/templates/insights/fragments/overview/index.html new file mode 100644 index 00000000..2fa468e1 --- /dev/null +++ b/app/templates/insights/fragments/overview/index.html @@ -0,0 +1,283 @@ +{% load i18n %} + +
+
+
+ + +
+
+
+
+ {% if view_type == 'table' %} +
+ +
+
+ +
+ {% endif %} +
+
+ + + + + +
+
+ {% if total_table %} + {% if view_type == "table" %} +
+
+ +
+ + + + + + + + + + + {% for item in total_table.values %} +{# Top level row #} + {% include "insights/fragments/overview/_row.html" with node=item is_root=True row_class="font-semibold" empty_label=level_1.empty search_path=item.search_path %} + +{# Second level rows #} + {% if show_level_2 %} + {% for child in item.children.values %} + {% if child.name or item.children.values|length > 1 %} + {% include "insights/fragments/overview/_row.html" with node=child row_class="bg-base-200" indent="ps-6" icon=level_2.icon empty_label=level_2.empty search_path=child.search_path %} + +{# Third level rows #} + {% if show_level_3 %} + {% for grandchild in child.children.values %} + {% if grandchild.name or child.children.values|length > 1 %} + {% include "insights/fragments/overview/_row.html" with node=grandchild row_class="bg-base-300" indent="ps-10" icon=level_3.icon empty_label=level_3.empty search_path=grandchild.search_path %} + {% endif %} + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + +
{{ level_1.singular }}{% trans 'Income' %}{% trans 'Expense' %}{% trans 'Total' %}
+
+
+
+ + {% elif view_type == "bars" %} +
+
+
+ +
+
+
+ + {{ total_table|json_script:"overviewData" }} + + + {% endif %} + {% else %} + + {% endif %} +
diff --git a/app/templates/insights/pages/index.html b/app/templates/insights/pages/index.html index 97a0c4d0..6410e4d1 100644 --- a/app/templates/insights/pages/index.html +++ b/app/templates/insights/pages/index.html @@ -93,6 +93,16 @@ hx-get="{% url 'category_overview' %}" >{% trans 'Categories Overview' %} + + From fef5c4807766ec9c7b5037fc671a4c12a678e104 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 12 Sep 2026 17:33:16 -0300 Subject: [PATCH 2/2] fix(ui): reserve scrollbar space to prevent layout shift --- frontend/src/styles/tailwind.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 66de927e..38fa9c6f 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -89,6 +89,16 @@ } +@layer base { + /* Always reserve space for the scrollbar so content doesn't shift when it appears. + `html:root` outranks daisyUI's `:root` rule, which resets the gutter to `unset` + unless a modal/drawer is open. */ + html:root { + scrollbar-gutter: stable; + } +} + + @layer utilities { .textarea { min-height: unset;