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 @@ - -