From 82211567d004fbeaf4c8d7b087a070726454d702 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 12 Sep 2026 18:02:24 -0300 Subject: [PATCH] feat(insights): replace fixed overviews with a single arrangeable overview Categories, Tags and Entities Overview are merged into one Overview page. Levels are chips that can be dragged (or moved with the arrow keys) to set the hierarchy and clicked to switch off, so any ordering of the three is reachable from the same page. Only the results reload on change; the controls stay in place. The chip order is kept in the session and defaults to categories by tags, matching the old Categories Overview. --- app/apps/insights/urls.py | 17 +- app/apps/insights/utils/overview.py | 39 +- app/apps/insights/views.py | 156 ++++---- .../insights/fragments/overview/_results.html | 195 ++++++++++ .../insights/fragments/overview/index.html | 361 +++++++----------- app/templates/insights/pages/index.html | 14 +- 6 files changed, 459 insertions(+), 323 deletions(-) create mode 100644 app/templates/insights/fragments/overview/_results.html diff --git a/app/apps/insights/urls.py b/app/apps/insights/urls.py index f50a73a4..3dc49bef 100644 --- a/app/apps/insights/urls.py +++ b/app/apps/insights/urls.py @@ -30,19 +30,14 @@ urlpatterns = [ name="category_sum_by_currency", ), path( - "insights/category-overview/", - views.category_overview, - name="category_overview", + "insights/overview/", + views.overview, + name="insights_overview", ), path( - "insights/tag-overview/", - views.tag_overview, - name="tag_overview", - ), - path( - "insights/entity-overview/", - views.entity_overview, - name="entity_overview", + "insights/overview/results/", + views.overview_results, + name="insights_overview_results", ), path( "insights/late-transactions/", diff --git a/app/apps/insights/utils/overview.py b/app/apps/insights/utils/overview.py index cfa4d168..4b2f4472 100644 --- a/app/apps/insights/utils/overview.py +++ b/app/apps/insights/utils/overview.py @@ -8,15 +8,16 @@ 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. +# Grouping levels the overview can be built from. Any ordering of these keys is +# a valid hierarchy, which is what lets the user arrange the chain freely. LEVELS = { "categories": {"field": "category", "name": "category__name"}, "tags": {"field": "tags", "name": "tags__name"}, "entities": {"field": "entities", "name": "entities__name"}, } +LEVEL_KEYS = tuple(LEVELS) + CURRENCY_FIELDS = ( "account__currency", "account__currency__code", @@ -234,3 +235,35 @@ def get_grouped_totals( ) return result + + +def _known(values): + """Drop anything that is not a level, and any repeat.""" + levels = [] + for value in values: + if value in LEVEL_KEYS and value not in levels: + levels.append(value) + return levels + + +def clean_chain(values): + """ + The full chain in the submitted order. + + Every level always has a chip, switched on or not, so a short or unknown + submission is padded back out rather than rejected. + """ + chain = _known(values) + return chain + [key for key in LEVEL_KEYS if key not in chain] + + +def clean_levels(values, chain): + """ + The levels actually switched on, ordered by their place in ``chain``. + + Taking the order from the chain rather than from the submission keeps the + hierarchy and the chips in step even if the two disagree. Never empty: the + first chip stands in, since an overview with no levels has nothing to show. + """ + enabled = set(_known(values)) + return [key for key in chain if key in enabled] or list(chain[:1]) diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index ab5d86ec..ab3cfd5c 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -4,7 +4,6 @@ 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 @@ -22,7 +21,12 @@ from apps.insights.utils.category_explorer import ( get_category_sums_by_account, get_category_sums_by_currency, ) -from apps.insights.utils.overview import get_grouped_totals +from apps.insights.utils.overview import ( + LEVEL_KEYS, + clean_chain, + clean_levels, + get_grouped_totals, +) from apps.insights.utils.sankey import ( generate_sankey_data_by_account, generate_sankey_data_by_currency, @@ -33,9 +37,7 @@ 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. +# Labels for the grouping levels the overview can be built from. OVERVIEW_LEVELS = { "categories": { "label": _("Categories"), @@ -61,54 +63,6 @@ OVERVIEW_LEVELS = { } -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"]) def index(request): @@ -247,28 +201,102 @@ def category_sum_by_currency(request): ) +OVERVIEW_SESSION_PREFIX = "insights_overview" + +# What a first visit shows: categories broken down by tags, as the old +# Categories Overview did, with entities available but switched off. +OVERVIEW_DEFAULT_LEVELS = ["categories", "tags"] + + +def _overview_setting(request, name, default): + """Read a control from the query string, falling back to the last one used.""" + key = f"{OVERVIEW_SESSION_PREFIX}_{name}" + if name in request.GET: + request.session[key] = request.GET[name] + return request.GET[name] + return request.session.get(key, default) + + +def _overview_chain(request): + """The chip order and the switched on levels, from the request or session.""" + chain_key = f"{OVERVIEW_SESSION_PREFIX}_chain" + levels_key = f"{OVERVIEW_SESSION_PREFIX}_levels" + + if "chain" in request.GET: + chain = clean_chain(request.GET.getlist("chain")) + levels = clean_levels(request.GET.getlist("level"), chain) + request.session[chain_key] = chain + request.session[levels_key] = levels + else: + chain = clean_chain(request.session.get(chain_key, list(LEVEL_KEYS))) + levels = clean_levels( + request.session.get(levels_key, OVERVIEW_DEFAULT_LEVELS), chain + ) + + return chain, levels + + @only_htmx @login_required @require_http_methods(["GET"]) -def category_overview(request): - return _render_overview( - request, ("categories", "tags", "entities"), "category_overview" +def overview(request): + """ + One overview whose level chain the user arranges. + + Renders only the controls; they stay put while the results below them + reload, so rearranging the chain never pulls the chips out from under the + pointer. The chips hold the order, which is why nothing here rewrites it. + """ + chain, levels = _overview_chain(request) + + return render( + request, + "insights/fragments/overview/index.html", + { + "chips": [ + { + "key": key, + "meta": OVERVIEW_LEVELS[key], + "enabled": key in levels, + "position": levels.index(key) + 1 if key in levels else "", + } + for key in chain + ], + "view_type": _overview_setting(request, "view_type", "table"), + "showing": _overview_setting(request, "showing", "final"), + }, ) @only_htmx @login_required @require_http_methods(["GET"]) -def tag_overview(request): - return _render_overview(request, ("tags", "categories", "entities"), "tag_overview") +def overview_results(request): + """The table or chart for the arrangement the controls submitted.""" + chain, levels = _overview_chain(request) + view_type = _overview_setting(request, "view_type", "table") + showing = _overview_setting(request, "showing", "final") + total_table = get_grouped_totals( + transactions_queryset=get_transactions(request, include_silent=True), + levels=levels, + showing=showing, + ignore_empty=False, + depth=len(levels), + ) -@only_htmx -@login_required -@require_http_methods(["GET"]) -def entity_overview(request): - return _render_overview( - request, ("entities", "categories", "tags"), "entity_overview" + return render( + request, + "insights/fragments/overview/_results.html", + { + "total_table": total_table, + "view_type": view_type, + "showing": showing, + "level_1": OVERVIEW_LEVELS[levels[0]], + "level_2": OVERVIEW_LEVELS[levels[1]] if len(levels) > 1 else None, + "level_3": OVERVIEW_LEVELS[levels[2]] if len(levels) > 2 else None, + "empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"], + }, ) diff --git a/app/templates/insights/fragments/overview/_results.html b/app/templates/insights/fragments/overview/_results.html new file mode 100644 index 00000000..3e7b2385 --- /dev/null +++ b/app/templates/insights/fragments/overview/_results.html @@ -0,0 +1,195 @@ +{% load i18n %} +{% 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 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 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/fragments/overview/index.html b/app/templates/insights/fragments/overview/index.html index 2fa468e1..7cc5f817 100644 --- a/app/templates/insights/fragments/overview/index.html +++ b/app/templates/insights/fragments/overview/index.html @@ -1,7 +1,11 @@ {% load i18n %} +{% comment %} + Overview insight: the level chain is arranged here instead of being baked + into the view. Only #overview-results reloads when something changes, so the + controls never get pulled out from under the pointer. +{% endcomment %} -
+
-
-
- {% if view_type == 'table' %} -
- -
-
- -
- {% endif %} +
+
+
+ {% for chip in chips %} + {# The hidden inputs sit outside .join: it rounds its end from :last-child, which they would otherwise be #} +
+ {# --radius-field is what .join reads for its outer corners, so overriding it here turns the chip into a pill #} +
+ + +
+ + +
+ {% endfor %} +
+
- {% 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 %} + + function commit() { + refresh(); + window.dispatchEvent(new CustomEvent('updated')); + } + + function move(chip, step) { + var sibling = step < 0 ? chip.previousElementSibling : chip.nextElementSibling; + if (!sibling) return false; + if (step < 0) { + root.insertBefore(chip, sibling); + } else { + root.insertBefore(sibling, chip); + } + return true; + } + + root.addEventListener('click', function (event) { + var toggle = event.target.closest('[data-toggle]'); + if (!toggle || !root.contains(toggle)) return; + if (toggle.getAttribute('aria-disabled') === 'true') return; + toggle.closest('.level-chip').classList.toggle('level-off'); + commit(); + }); + + // Dragging is mouse and touch only, so the handle also reorders with + // the arrow keys rather than leaving the chain keyboard inaccessible. + root.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + var handle = event.target.closest('[data-handle]'); + if (!handle || !root.contains(handle)) return; + if (!move(handle.closest('.level-chip'), event.key === 'ArrowLeft' ? -1 : 1)) return; + event.preventDefault(); + handle.focus(); + commit(); + }); + + // Reached from the x-sort handler once Sortable has moved the chip. + root.levelChainCommit = commit; + + refresh(); + } + + function levelChainSorted() { + var root = document.getElementById('level-chain'); + if (root && root.levelChainCommit) root.levelChainCommit(); + } + + // Wired up here rather than with a hyperscript init, so it does not + // depend on which of the two runs first after an htmx swap. + setupLevelChain(document.getElementById('level-chain')); +
diff --git a/app/templates/insights/pages/index.html b/app/templates/insights/pages/index.html index 6410e4d1..fcaed497 100644 --- a/app/templates/insights/pages/index.html +++ b/app/templates/insights/pages/index.html @@ -90,18 +90,8 @@ - -