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" %}
+
+
+
+
+
+
+
+
{{ level_1.singular }}
+
{% trans 'Income' %}
+
{% trans 'Expense' %}
+
{% trans 'Total' %}
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+ {% 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" %}
-
-
-
-
-
-
-
-
{{ level_1.singular }}
-
{% trans 'Income' %}
-
{% trans 'Expense' %}
-
{% trans 'Total' %}
-
-
-
- {% 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 %}
-
-
-
-
-
-
- {% 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'));
+