From 40b0c7ed7ba74303808da58bdc2b1a75623a7e14 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Sun, 18 Oct 2026 15:59:35 +0200 Subject: [PATCH] Add expense type breakdown chart to dashboard --- .../expenses/static/expenses/css/base.css | 71 +++++-- .../templates/expenses/dashboard.html | 173 ++++++++++++++++- .../expenses/tests/test_dashboard.py | 183 ++++++++++++++++++ expenses_manager/expenses/tests/test_smoke.py | 2 + expenses_manager/expenses/views.py | 46 ++++- 5 files changed, 459 insertions(+), 16 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 8be272a..3be877b 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -561,36 +561,85 @@ button[type="submit"]:hover, } /* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo - lleva, su control a la derecha. El min-height reserva el alto del selector - también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la - misma altura por construcción y no por casualidad. Por eso no se compensa - tocando el alto del canvas: eso cuadraría solo a un ancho concreto. + lleva, su control a la derecha. Cuando el control no cabe junto al título, + baja a una segunda línea. Eso pasa a anchos de escritorio normales: el de + tipo de gasto lleva dos grupos de controles y una tarjeta mide ~340 px con + cuatro por fila. El min-height reserva esas dos líneas (título de 2.25rem, + 0.5rem de separación y controles de 2.25rem) en todas las tarjetas, tengan + controles o no. Así los canvas de una misma fila arrancan a la misma altura + por construcción y no por casualidad. Por eso no se compensa tocando el alto + del canvas: eso cuadraría solo a un ancho concreto. Los márgenes son los que traía el h2, para no mover el ritmo vertical. */ .chart-header { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; - gap: 1rem; - min-height: 2.5rem; + align-content: flex-start; + gap: 0.5rem 1rem; + min-height: 5rem; margin: 1.5rem 0 0.75rem; } /* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height - dejaría de mandar. */ + dejaría de mandar. El alto mínimo iguala la línea del título a la de los + controles: haya o no un control al lado, el título queda a la misma altura + en todas las tarjetas. */ .chart-header h2 { margin: 0; + min-height: 2.25rem; + display: flex; + align-items: center; } -/* El alto del control se fija por debajo del min-height de la cabecera. Así la - cabecera mide siempre el min-height en las dos tarjetas y la alineación no - depende de cuánto mida un en cada navegador, que es justo lo que haría que + cuadrara en uno y no en otro. */ .chart-header select { height: 2.25rem; padding-top: 0; padding-bottom: 0; } +/* Grupo de botones de icono que se excluyen entre sí (la forma del gráfico). + Mismo alto que el + + + + +
+ + + +
+ + {% endif %} + + {% if expense_type_classified_count < 2 %} +
+

Clasifica al menos dos categorías para ver este desglose

+ Ir a categorías +
+ {% elif kpi_total %} +
+ +
+ {% else %} +

No hay gastos en este periodo

+ {% endif %} + + + + +
+

Gastos Recientes

@@ -267,9 +320,8 @@
-
- - + +

Evolución anual por cuenta ({{ selected_year }})

diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index 3e67faf..22f80d9 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -1,6 +1,8 @@ import pytest from datetime import date, timedelta from decimal import Decimal +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from expenses.models import Account, Expense, Category, Income, Transfer @@ -481,3 +483,184 @@ def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user ) assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva assert response.context['kpi_balance'] == expected + + +# -------------------------------------------------------------------------- +# Desglose por tipo de gasto +# -------------------------------------------------------------------------- + +def _breakdown(response): + return {row['label']: row['total'] for row in response.context['expense_type_breakdown']} + + +def test_expense_type_breakdown_groups_by_effective_type(auth_client, user, account): + home = Category.objects.create( + name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + power = Category.objects.create(name='Luz', owner=user, parent=home) + fun = Category.objects.create( + name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + loose = Category.objects.create(name='Varios', owner=user) + for category, amount in [(home, '500'), (power, '60'), (fun, '40'), (loose, '7')]: + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal(amount), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # La luz hereda "Fijo esencial" de Casa; Varios no tiene tipo en ninguna + # parte de la cadena y va a "Sin clasificar" en vez de desaparecer. + assert _breakdown(response) == { + 'Fijo esencial': Decimal('560'), + 'Fijo prescindible': Decimal('0'), + 'Variable esencial': Decimal('0'), + 'Variable prescindible': Decimal('40'), + 'Sin clasificar': Decimal('7'), + } + assert sum(_breakdown(response).values()) == response.context['kpi_total'] + assert 'id="expenseTypeChart"' in response.content.decode() + + +def test_expense_type_breakdown_follows_the_selected_period(auth_client, user, account, category): + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('10'), date=date(2024, 3, 1), + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('99'), date=date(2024, 4, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) + + assert _breakdown(response)['Sin clasificar'] == Decimal('10') + + +def test_expense_type_breakdown_ignores_transfers(auth_client, user, account): + other = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + _make_transfer(user, account, other, amount="400", fee="1.50") + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # Solo la comisión es gasto de verdad. + assert sum(_breakdown(response).values()) == Decimal('1.50') + + +def _classify_two_categories(user): + Category.objects.create( + name='Alquiler', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + Category.objects.create( + name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + + +def test_expense_type_breakdown_shows_empty_state_without_expenses(auth_client, user): + _classify_two_categories(user) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert sum(_breakdown(response).values()) == 0 + content = response.content.decode() + assert 'id="expenseTypeChart"' not in content + assert 'No hay gastos en este periodo' in content + assert 'Clasifica al menos dos categorías' not in content + + +def test_expense_type_breakdown_asks_to_classify_with_fewer_than_two( + auth_client, user, account +): + # Una sola categoría clasificada; la hija hereda pero no cuenta como + # clasificada, porque el usuario no la ha clasificado él. + parent = Category.objects.create( + name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + child = Category.objects.create(name='Luz', owner=user, parent=parent) + Expense.objects.create( + owner=user, account=account, category=child, + amount=Decimal('60'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['expense_type_classified_count'] == 1 + content = response.content.decode() + assert 'Clasifica al menos dos categorías' in content + assert 'href="%s"' % reverse('category_list') in content + assert 'id="expenseTypeChart"' not in content + assert 'id="expenseTypeGrouping"' not in content + assert 'No hay gastos en este periodo' not in content + + +@pytest.mark.parametrize('classified, asks_to_classify', [(1, True), (2, False)]) +def test_expense_type_breakdown_classification_threshold( + auth_client, user, account, classified, asks_to_classify +): + types = [Category.EXPENSE_TYPE_FIXED_ESSENTIAL, Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL] + for i in range(classified): + category = Category.objects.create(name=f'Clasificada {i}', owner=user, expense_type=types[i]) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('10'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['expense_type_classified_count'] == classified + content = response.content.decode() + assert ('Clasifica al menos dos categorías' in content) is asks_to_classify + assert ('id="expenseTypeChart"' in content) is not asks_to_classify + + +def test_expense_type_breakdown_renders_grouping_and_shape_controls( + auth_client, user, account +): + _classify_two_categories(user) + Expense.objects.create( + owner=user, account=account, category=Category.objects.get(name='Ocio'), + amount=Decimal('40'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + content = response.content.decode() + assert 'id="expenseTypeGrouping"' in content + assert 'role="group" aria-label="Forma del gráfico"' in content + assert content.count('data-shape=') == 3 + assert content.count('aria-pressed="true"') == 1 + + +def _dashboard_queries(client): + with CaptureQueriesContext(connection) as ctx: + client.get(reverse('dashboard'), {'year': 2024}) + return len(ctx.captured_queries) + + +def test_dashboard_query_count_does_not_grow_with_categories_and_expenses( + auth_client, user, account +): + root = Category.objects.create( + name='Raíz', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + Expense.objects.create( + owner=user, account=account, category=root, + amount=Decimal('1'), date=date(2024, 3, 1), + ) + baseline = _dashboard_queries(auth_client) + + # Más categorías anidadas y más gastos que las 7 filas de recent_expenses: + # ni la herencia del desglose ni el listado reciente deben pedir una + # consulta por fila. + parent = root + for i in range(10): + parent = Category.objects.create(name=f'Nivel {i}', owner=user, parent=parent) + Expense.objects.create( + owner=user, account=account, category=parent, + amount=Decimal('1'), date=date(2024, 3, 1), + ) + + assert _dashboard_queries(auth_client) == baseline diff --git a/expenses_manager/expenses/tests/test_smoke.py b/expenses_manager/expenses/tests/test_smoke.py index 6fa49ae..70d566a 100644 --- a/expenses_manager/expenses/tests/test_smoke.py +++ b/expenses_manager/expenses/tests/test_smoke.py @@ -104,6 +104,8 @@ DASHBOARD_CONTEXT_KEYS = { "chart_type", "by_category", "by_category_chart", + "expense_type_breakdown", + "expense_type_classified_count", "accounts_charts", # Filtros de periodo, cuenta y rango libre "period", diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 28c251d..1191a02 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -144,6 +144,7 @@ def _category_tree(categories): rows.append({ "category": category, "depth": depth, + "expense_type": expense_type, "expense_type_label": labels.get(expense_type, ""), "expense_type_inherited": not category.expense_type, }) @@ -153,6 +154,37 @@ def _category_tree(categories): return rows +def _expense_type_breakdown(user, expenses): + """Suma `expenses` por el tipo de gasto efectivo de su categoría. + + Devuelve siempre las cinco filas (los cuatro tipos y "Sin clasificar" al + final), aunque alguna valga cero: así cada tipo conserva su color en el + gráfico. Lo que no tiene tipo, ni propio ni heredado, va a "Sin + clasificar" y nunca se descarta, para que la suma cuadre con el total. + + Devuelve también cuántas categorías tienen tipo propio (sin contar las + que lo heredan), para avisar cuando hay demasiado poco clasificado. + + Son dos consultas fijas: las categorías, de las que _category_tree saca el + tipo heredado, y los gastos agrupados por categoría.""" + tree = _category_tree(Category.objects.filter(owner=user)) + effective = {row["category"].id: row["expense_type"] for row in tree} + classified_count = sum(1 for row in tree if row["category"].expense_type) + + labels = dict(Category.EXPENSE_TYPE_CHOICES) + labels[""] = "Sin clasificar" + totals = dict.fromkeys(labels, Decimal("0")) + + for row in expenses.values("category").annotate(total=Sum("amount")): + totals[effective.get(row["category"], "")] += row["total"] + + rows = [ + {"expense_type": key, "label": labels[key], "total": total} + for key, total in totals.items() + ] + return rows, classified_count + + def _without_transfers(movements): """Quita las patas de traspaso de un queryset de gastos o de ingresos. @@ -846,6 +878,12 @@ def dashboard(request): "category__name": cat_name, "total": total_cat }) + + # Sobre el mismo conjunto que los KPIs (periodo, cuenta y sin traspasos), + # así que sus cinco porciones suman kpi_total. + expense_type_breakdown, expense_type_classified_count = _expense_type_breakdown( + request.user, expenses_filtered + ) #Graphic # El eje se construye una sola vez (claves + etiquetas + agrupación) y las @@ -999,7 +1037,11 @@ def dashboard(request): goals = Goal.objects.filter(owner=request.user) # Recent expenses - recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7] + recent_expenses = ( + Expense.objects.filter(owner=request.user) + .select_related("category", "account") + .order_by('-date', '-id')[:7] + ) # Send the data to the dashboard return render( @@ -1010,6 +1052,8 @@ def dashboard(request): "today": today, "by_category": by_category, "by_category_chart": by_category_chart, + "expense_type_breakdown": expense_type_breakdown, + "expense_type_classified_count": expense_type_classified_count, "chart_labels": chart_labels, "chart_data": chart_totals, # La otra serie del gráfico de ingresos contra gastos. Se manda una