From 3c313aa4e410ffc12508d823b7e3a6c9600b3e11 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 18:21:21 +0200 Subject: [PATCH 1/4] Fix dashboard filter styling, comparison table ordering and duplicate CSS rule --- .../expenses/static/expenses/css/base.css | 5 +- .../templates/expenses/dashboard.html | 74 ++++++++++--------- .../expenses/tests/test_dashboard.py | 55 ++++++++++++++ expenses_manager/expenses/views.py | 8 ++ 4 files changed, 102 insertions(+), 40 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 6593c5a..196266f 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -617,6 +617,7 @@ button[type="submit"]:hover, .comparison { background: var(--color-surface-alt); padding: 1rem; + margin-top: 1rem; margin-bottom: 2rem; border-radius: 8px; } @@ -1067,10 +1068,6 @@ tr:hover { color: var(--color-danger-accent); } -.comparison { - margin-top: 1rem; -} - @media (max-width: 480px) { .movement-account { display: none; diff --git a/expenses_manager/expenses/templates/expenses/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index f1fdb72..6b7e5f7 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -55,49 +55,51 @@
- {% if period %} - - {% endif %} +
+ {% if period %} + + {% endif %} - - - - {% include "expenses/_date_range.html" %} - - {% if not period %} - - + + {% for acc in accounts %} + {% endfor %} - - - {% endif %} + {% include "expenses/_date_range.html" %} - + {% if not period %} + + - + + + {% endif %} + + + + +
{% if range_active %} diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index 469db7f..effc428 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -172,6 +172,61 @@ def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account assert rows[category.name]['difference'] == 10.0 +def test_dashboard_comparison_is_ordered_by_absolute_difference( + auth_client, user, account +): + # Las categorías se juntan con un set, y el orden de un set no es estable + # entre ejecuciones: sin ordenar, la tabla sale distinta en cada recarga. + moved_little = Category.objects.create(owner=user, name="Poco") + moved_a_lot = Category.objects.create(owner=user, name="Mucho") + + for cat, previous, current in ( + (moved_little, "10", "15"), + (moved_a_lot, "10", "90"), + ): + Expense.objects.create( + owner=user, account=account, category=cat, + amount=Decimal(previous), date=date(2024, 2, 15), + ) + Expense.objects.create( + owner=user, account=account, category=cat, + amount=Decimal(current), date=date(2024, 3, 10), + ) + + response = auth_client.get( + reverse('dashboard'), + {'year': 2024, 'month': 3, 'compare': '1'}, + ) + + order = [row['category'] for row in response.context['category_comparison']] + assert order == ["Mucho", "Poco"] + + +def test_dashboard_comparison_breaks_ties_by_category_name( + auth_client, user, account +): + # Misma diferencia en las tres: sin desempate explícito por nombre + # seguirían bailando entre recargas aunque el orden principal sea estable. + for name in ("Zeta", "Alfa", "Media"): + cat = Category.objects.create(owner=user, name=name) + Expense.objects.create( + owner=user, account=account, category=cat, + amount=Decimal("10"), date=date(2024, 2, 15), + ) + Expense.objects.create( + owner=user, account=account, category=cat, + amount=Decimal("20"), date=date(2024, 3, 10), + ) + + response = auth_client.get( + reverse('dashboard'), + {'year': 2024, 'month': 3, 'compare': '1'}, + ) + + order = [row['category'] for row in response.context['category_comparison']] + assert order == ["Alfa", "Media", "Zeta"] + + def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client): response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 30fad4e..6d79a78 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -900,6 +900,14 @@ def dashboard(request): "difference": cur_t - prev_t, "difference_abs": abs(cur_t - prev_t) }) + + # El orden de un set de Python no es estable entre ejecuciones, así que + # la tabla salía distinta en cada recarga. Primero lo que más se ha + # movido, y el nombre como desempate explícito: sin él, dos categorías + # con la misma diferencia seguirían bailando. + category_comparison.sort( + key=lambda row: (-row["difference_abs"], row["category"]) + ) # Anual evolution by accounts accounts_charts = _build_account_charts( -- 2.45.2 From 27703bd47c9e5eefa20aba5bf0778c846a76a2fc Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 02:34:31 +0200 Subject: [PATCH 2/4] Add income vs expense series to dashboard --- .../expenses/tests/test_dashboard.py | 207 +++++++++++++++++- expenses_manager/expenses/views.py | 174 ++++++++++----- 2 files changed, 329 insertions(+), 52 deletions(-) diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index effc428..3e67faf 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -1,8 +1,8 @@ import pytest -from datetime import date +from datetime import date, timedelta from decimal import Decimal from django.urls import reverse -from expenses.models import Account, Expense, Category, Income +from expenses.models import Account, Expense, Category, Income, Transfer pytestmark = pytest.mark.django_db @@ -227,6 +227,209 @@ def test_dashboard_comparison_breaks_ties_by_category_name( assert order == ["Alfa", "Media", "Zeta"] +# -------------------------------------------------------------------------- +# Serie de ingresos contra gastos +# -------------------------------------------------------------------------- + + +def _make_transfer(user, from_account, to_account, amount="400", on=None, fee=None): + transfer = Transfer( + owner=user, + date=on or date(2024, 3, 10), + amount=Decimal(amount), + from_account=from_account, + to_account=to_account, + fee_amount=Decimal(fee) if fee is not None else None, + ) + transfer.sync() + return transfer + + +def test_dashboard_sends_both_series_on_the_same_axis( + auth_client, user, account, category +): + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("30"), date=date(2024, 5, 10), + ) + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 3, 10), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + expenses = response.context['chart_data'] + incomes = response.context['chart_income_data'] + # Mismo eje para las dos: si cada una montara el suyo, se desalinearían. + assert len(incomes) == len(response.context['chart_labels']) == len(expenses) + assert incomes[2] == 1500.0 # marzo + assert expenses[4] == 30.0 # mayo + assert incomes[4] == 0 # mayo no tuvo ingresos: cero, no hueco + + +def test_dashboard_income_series_is_zero_filled_without_movements(auth_client): + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['chart_income_data'] == [0] * 12 + + +def test_dashboard_income_series_is_zero_filled_in_a_month_without_movements( + auth_client +): + response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) + + assert response.context['chart_income_data'] == [0] * 31 + + +def test_dashboard_both_series_ignore_a_transfer(user, auth_client, account): + other = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + _make_transfer(user, account, other, amount="400") + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # Mandarse dinero a una cuenta propia no es ingreso ni gasto: si se + # excluyera solo de los gastos, el gráfico daría un pico de ingresos cada + # vez que el usuario se traspasa la nómina. + assert sum(response.context['chart_income_data']) == 0 + assert sum(response.context['chart_data']) == 0 + + +def test_dashboard_counts_the_transfer_fee_as_an_expense(user, auth_client, 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}) + + # La comisión va con is_transfer=False: sale del patrimonio de verdad. + assert sum(response.context['chart_data']) == 1.5 + assert sum(response.context['chart_income_data']) == 0 + + +def test_dashboard_income_series_follows_the_day_axis_in_a_short_range( + auth_client, user, account +): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 3, 5), + ) + + response = auth_client.get( + reverse('dashboard'), + {'date_from': '2024-03-01', 'date_to': '2024-03-30'}, # 30 días + ) + + assert response.context['chart_type'] == 'day' + incomes = response.context['chart_income_data'] + assert len(incomes) == 30 + assert incomes[4] == 1500.0 + assert incomes[0] == 0 + + +def test_dashboard_income_series_follows_the_month_axis_in_a_long_range( + auth_client, user, account +): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 2, 5), + ) + + response = auth_client.get( + reverse('dashboard'), + {'date_from': '2024-01-01', 'date_to': '2024-04-29'}, # 120 días + ) + + assert response.context['chart_type'] == 'month' + incomes = response.context['chart_income_data'] + assert len(incomes) == 4 + assert incomes[1] == 1500.0 + + +def test_dashboard_income_series_matches_the_62_day_threshold( + auth_client, user, account +): + start = date(2024, 3, 1) + at_limit = auth_client.get( + reverse('dashboard'), + {'date_from': start.isoformat(), 'date_to': (start + timedelta(days=61)).isoformat()}, + ) + over_limit = auth_client.get( + reverse('dashboard'), + {'date_from': start.isoformat(), 'date_to': (start + timedelta(days=62)).isoformat()}, + ) + + # La serie de ingresos usa el mismo umbral que la de gastos, no el suyo. + assert at_limit.context['chart_type'] == 'day' + assert len(at_limit.context['chart_income_data']) == 62 + assert over_limit.context['chart_type'] == 'month' + assert len(over_limit.context['chart_income_data']) == 3 # mar, abr, may + + +def test_dashboard_open_range_axis_covers_older_incomes(auth_client, user, account, category): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 3, 1), + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("30"), date=date(2024, 3, 20), + ) + + response = auth_client.get(reverse('dashboard'), {'date_to': '2024-03-25'}) + + # Sin date_from el eje se acota con el movimiento más antiguo de las dos + # series: midiendo solo el gasto más viejo, ese ingreso se caería del eje. + assert response.context['chart_labels'][0] == "01/03" + assert response.context['chart_income_data'][0] == 1500.0 + + +def test_dashboard_income_series_does_not_leak_between_users( + auth_client, user, account, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + their_account = Account.objects.create( + owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True + ) + Income.objects.create( + owner=intruder, account=their_account, name="Ajena", + amount=Decimal("999"), date=date(2024, 3, 10), + ) + Income.objects.create( + owner=user, account=account, name="Mía", + amount=Decimal("100"), date=date(2024, 3, 10), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert sum(response.context['chart_income_data']) == 100.0 + + +def test_dashboard_income_series_respects_the_account_filter( + auth_client, user, account +): + other = Account.objects.create( + owner=user, name="Otra", initial_balance=Decimal("0"), active=True + ) + Income.objects.create( + owner=user, account=account, name="Aquí", + amount=Decimal("100"), date=date(2024, 3, 10), + ) + Income.objects.create( + owner=user, account=other, name="Allí", + amount=Decimal("500"), date=date(2024, 3, 10), + ) + + response = auth_client.get( + reverse('dashboard'), {'year': 2024, 'account': account.id} + ) + + assert sum(response.context['chart_income_data']) == 100.0 + + def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client): response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 6d79a78..cdfd28b 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -143,6 +143,16 @@ def _category_tree(categories): return rows +def _without_transfers(movements): + """Quita las patas de traspaso de un queryset de gastos o de ingresos. + + Único sitio donde se decide qué cuenta como movimiento real en los análisis. + Si cada pantalla lo filtrara por su cuenta acabaría habiendo dos criterios, + y bastaría tocar uno para que los números dejaran de cuadrar entre sí. + """ + return movements.exclude(is_transfer=True) + + def _analysis_expenses(user): """Gastos para análisis: excluye los traspasos entre cuentas propias. @@ -154,7 +164,64 @@ def _analysis_expenses(user): monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan, y filtrarlos descuadraría todos los saldos. """ - return Expense.objects.filter(owner=user).exclude(is_transfer=True) + return _without_transfers(Expense.objects.filter(owner=user)) + + +def _analysis_incomes(user): + """Ingresos para análisis: el espejo de _analysis_expenses. + + Hace falta porque si los traspasos se excluyeran solo del lado de los + gastos, el gráfico de ingresos contra gastos daría un pico de ingresos cada + vez que el usuario se traspasa la nómina a otra cuenta propia. + + La misma advertencia que en los gastos: esto no vale para saldos. + """ + return _without_transfers(Income.objects.filter(owner=user)) + + +def _bucket_totals(movements, bucket): + """Suma importes agrupados según el eje del gráfico del dashboard. + + `bucket` lo decide quien construye el eje, y las dos series del gráfico de + ingresos contra gastos lo comparten: si cada una se agrupara por su cuenta, + los ejes divergirían en cuanto una tuviera un hueco que la otra no. + + Son dos consultas separadas (una por serie) que se cruzan después en Python. + No se puede juntar en un annotate() de dos Sum sobre el mismo queryset: el + JOIN multiplica filas e infla los totales. + """ + if bucket == "date": + # Agrupar por el propio campo, no con TruncDate: "date" ya es un + # DateField y TruncDate le aplicaría una conversión de zona horaria + # que en SQLite falla si no está la base de datos tz. + rows = movements.values("date").annotate(total=Sum("amount")) + return {row["date"]: float(row["total"]) for row in rows} + + if bucket == "year_month": + rows = ( + movements.annotate(bucket=TruncMonth("date")) + .values("bucket") + .annotate(total=Sum("amount")) + ) + return { + (row["bucket"].year, row["bucket"].month): float(row["total"]) + for row in rows + } + + if bucket == "day_of_month": + rows = ( + movements.annotate(day=ExtractDay("date")) + .values("day") + .annotate(total=Sum("amount")) + ) + return {row["day"]: float(row["total"]) for row in rows} + + rows = ( + movements.annotate(month=ExtractMonth("date")) + .values("month") + .annotate(total=Sum("amount")) + ) + return {row["month"]: float(row["total"]) for row in rows} def _reject_transfer_leg(request, movement): @@ -720,16 +787,27 @@ def dashboard(request): if account_id: expenses = expenses.filter(account_id=account_id) + # Los ingresos van en paralelo y con los mismos filtros: son la otra serie + # del gráfico de ingresos contra gastos. + incomes = _analysis_incomes(request.user) + if account_id: + incomes = incomes.filter(account_id=account_id) + if range_active: expenses_filtered = expenses + incomes_filtered = incomes if date_from: expenses_filtered = expenses_filtered.filter(date__gte=date_from) + incomes_filtered = incomes_filtered.filter(date__gte=date_from) if date_to: expenses_filtered = expenses_filtered.filter(date__lte=date_to) + incomes_filtered = incomes_filtered.filter(date__lte=date_to) else: expenses_filtered = expenses.filter(date__year=selected_year) + incomes_filtered = incomes.filter(date__year=selected_year) if selected_month: expenses_filtered = expenses_filtered.filter(date__month=selected_month) + incomes_filtered = incomes_filtered.filter(date__month=selected_month) # Basic KPIs @@ -760,20 +838,35 @@ def dashboard(request): }) #Graphic + # El eje se construye una sola vez (claves + etiquetas + agrupación) y las + # dos series del gráfico de ingresos contra gastos se calculan contra él. + # Si cada serie montara su propio eje, dos huecos distintos las + # desalinearían y el gráfico mentiría. daily_average = 0 projected_end_of_month = 0 if range_active: # Con rango libre el eje no lo decide el mes sino la longitud del rango. - # Si falta un extremo se acota para poder medirlo: por abajo con el gasto - # más antiguo del conjunto, por arriba con hoy. - span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"] + # Si falta un extremo se acota para poder medirlo: por abajo con el + # movimiento más antiguo del conjunto, por arriba con hoy. + span_from = date_from + if span_from is None: + # Se mira el más antiguo de LAS DOS series: acotando solo por el + # gasto más viejo, los ingresos anteriores se caerían del eje sin + # que nada lo avisara. + firsts = [ + expenses_filtered.aggregate(first=Min("date"))["first"], + incomes_filtered.aggregate(first=Min("date"))["first"], + ] + firsts = [first for first in firsts if first is not None] + span_from = min(firsts) if firsts else None span_to = date_to or today if span_from is None: - # Sin gastos y sin fecha inicial no hay longitud que medir. + # Sin movimientos y sin fecha inicial no hay longitud que medir. + axis_keys = [] chart_labels = [] - chart_totals = [] chart_type = "month" + bucket = "year_month" else: span_days = (span_to - span_from).days + 1 daily_average = total_amount / span_days if span_days > 0 else 0 @@ -781,71 +874,49 @@ def dashboard(request): # un rango arbitrario no significa nada. if span_days <= RANGE_DAY_CHART_MAX_DAYS: - # Agrupar por el propio campo, no con TruncDate: "date" ya es un - # DateField y TruncDate le aplicaría una conversión de zona - # horaria que en SQLite falla si no está la base de datos tz. - by_day_qs = expenses_filtered.values("date").annotate( - total=Sum("amount") - ) - day_totals = {row["date"]: float(row["total"]) for row in by_day_qs} - - axis = [span_from + timedelta(days=i) for i in range(span_days)] - chart_labels = [d.strftime("%d/%m") for d in axis] - chart_totals = [day_totals.get(d, 0) for d in axis] + axis_keys = [span_from + timedelta(days=i) for i in range(span_days)] + chart_labels = [d.strftime("%d/%m") for d in axis_keys] chart_type = "day" + bucket = "date" else: - by_month_qs = ( - expenses_filtered.annotate(bucket=TruncMonth("date")) - .values("bucket") - .annotate(total=Sum("amount")) - ) - month_totals = { - (row["bucket"].year, row["bucket"].month): float(row["total"]) - for row in by_month_qs - } - - axis = [] + axis_keys = [] cursor = (span_from.year, span_from.month) last = (span_to.year, span_to.month) while cursor <= last: - axis.append(cursor) + axis_keys.append(cursor) y, m = cursor cursor = (y + 1, 1) if m == 12 else (y, m + 1) - chart_labels = [f"{m:02d}/{y}" for y, m in axis] - chart_totals = [month_totals.get(key, 0) for key in axis] + chart_labels = [f"{m:02d}/{y}" for y, m in axis_keys] chart_type = "month" + bucket = "year_month" elif selected_month: - by_day_qs = ( - expenses_filtered.annotate(day=ExtractDay("date")) - .values("day") - .annotate(total=Sum("amount")) - ) - day_totals = {row["day"]: float(row["total"]) for row in by_day_qs} - num_days = calendar.monthrange(selected_year, selected_month)[1] if selected_year == today.year and selected_month == today.month: days_passed = today.day else: days_passed = calendar.monthrange(selected_year, selected_month)[1] - + daily_average = total_amount / days_passed if days_passed > 0 else 0 total_days_in_month = calendar.monthrange(selected_year, selected_month)[1] projected_end_of_month = daily_average * total_days_in_month - - chart_labels = list(range(1, num_days + 1)) - chart_totals = [day_totals.get(d, 0) for d in chart_labels] + + axis_keys = list(range(1, num_days + 1)) + chart_labels = axis_keys chart_type = "day" + bucket = "day_of_month" else: - by_month_qs = ( - expenses_filtered.annotate(month=ExtractMonth("date")) - .values("month") - .annotate(total=Sum("amount")) - ) - month_totals = {row["month"]: float(row["total"]) for row in by_month_qs} + axis_keys = list(range(1, 13)) chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"] - chart_totals = [month_totals.get(m, 0) for m in range(1, 13)] chart_type = "month" + bucket = "month_of_year" + + # Los ceros son obligatorios: un día o un mes sin movimientos tiene que + # salir con 0, no desaparecer del eje. + expense_by_bucket = _bucket_totals(expenses_filtered, bucket) + income_by_bucket = _bucket_totals(incomes_filtered, bucket) + chart_totals = [expense_by_bucket.get(key, 0) for key in axis_keys] + chart_income_totals = [income_by_bucket.get(key, 0) for key in axis_keys] year_list = ( Expense.objects.filter(owner=request.user) @@ -931,6 +1002,9 @@ def dashboard(request): "by_category_chart": by_category_chart, "chart_labels": chart_labels, "chart_data": chart_totals, + # La otra serie del gráfico de ingresos contra gastos. Se manda una + # sola vez: el neto y los tres modos se resuelven en cliente. + "chart_income_data": chart_income_totals, "chart_type": chart_type, "year_list": year_list, "months": list(range(1, 13)), @@ -1166,7 +1240,7 @@ def income_list(request): # La tabla sigue listando los traspasos, pero el total mide ingreso real y # los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla. - totals = incomes.exclude(is_transfer=True).aggregate( + totals = _without_transfers(incomes).aggregate( total=Sum("amount"), count=Count("id") ) total_amount = totals["total"] or 0 -- 2.45.2 From 3eb36a1226d4de6cb5835a8b344ff3d9edd1b5ee Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 02:34:32 +0200 Subject: [PATCH 3/4] Add income vs expense chart with selectable modes --- .../expenses/static/expenses/css/base.css | 31 +++ .../expenses/static/expenses/js/charts.js | 20 ++ .../templates/expenses/dashboard.html | 206 +++++++++++++++++- 3 files changed, 255 insertions(+), 2 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 196266f..8be272a 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -560,6 +560,37 @@ button[type="submit"]:hover, min-width: 280px; } +/* 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. + Los márgenes son los que traía el h2, para no mover el ritmo vertical. */ +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + min-height: 2.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. */ +.chart-header h2 { + margin: 0; +} + +/* 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 + + + + + + +
+ +
+ + +
+
+

Distribución por Categorías

+
@@ -335,6 +356,187 @@ applyCartesianColors(chart, colors); }); + // --------------------------------------------------------------- + // Ingresos vs gastos: tres modos, todos en cliente + // --------------------------------------------------------------- + (function () { + const incomeData = {{ chart_income_data|safe }}; + const expenseData = data; + // El neto se resta aquí: las dos series vienen del servidor una sola + // vez y cambiar de modo no le pide nada. + const netData = incomeData.map(function (value, i) { + return value - (expenseData[i] || 0); + }); + + const canvas = document.getElementById('balanceChart'); + const modeSelect = document.getElementById('balanceChartMode'); + const STORAGE_KEY = 'dashboardBalanceChartMode'; + const MODES = ['bars', 'lines', 'net']; + let balanceChart = null; + + function storedMode() { + // localStorage puede fallar (modo privado, cookies bloqueadas): + // si no se puede leer, se asume el modo por defecto. + try { + const saved = localStorage.getItem(STORAGE_KEY); + return MODES.indexOf(saved) !== -1 ? saved : 'bars'; + } catch (e) { + return 'bars'; + } + } + + function rememberMode(mode) { + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch (e) { + // Sin persistencia, pero el gráfico ya ha cambiado. + } + } + + // Chart.js dibuja los datasets ordenados por `order` y recorriéndolos + // del revés, así que el de `order` MÁS BAJO se pinta ÚLTIMO y queda + // encima. El orden del array no manda: sin order explícito la línea + // del neto se quedaba por detrás de las barras. + const ORDER_NET = 0; + const ORDER_INCOMES = 1; + const ORDER_EXPENSES = 2; + + function datasetsFor(mode, colors) { + const incomes = { + label: 'Ingresos (€)', + data: incomeData, + backgroundColor: colors.successColor, + borderColor: colors.successColor, + borderWidth: 1, + order: ORDER_INCOMES + }; + const expenses = { + label: 'Gastos (€)', + data: expenseData, + backgroundColor: colors.dangerColor, + borderColor: colors.dangerColor, + borderWidth: 1, + order: ORDER_EXPENSES + }; + + if (mode === 'lines') { + incomes.tension = 0.3; + expenses.tension = 0.3; + incomes.fill = false; + expenses.fill = false; + return [incomes, expenses]; + } + + if (mode === 'net') { + return [incomes, expenses, { + label: 'Neto (€)', + data: netData, + type: 'line', + order: ORDER_NET, + borderColor: colors.primaryColor, + backgroundColor: colors.primaryColor, + borderWidth: 3, + tension: 0.3, + fill: false, + // Puntos con aro del color del fondo: así se recortan + // sobre las barras en vez de diluirse en ellas. + pointRadius: 4, + pointHoverRadius: 6, + pointBackgroundColor: colors.primaryColor, + pointBorderColor: colors.surfaceColor, + pointBorderWidth: 2 + }]; + } + + return [incomes, expenses]; + } + + function applyBalanceColors(chart, colors) { + chart.data.datasets.forEach(function (dataset) { + if (dataset.label === 'Ingresos (€)') { + dataset.backgroundColor = colors.successColor; + dataset.borderColor = colors.successColor; + } else if (dataset.label === 'Gastos (€)') { + dataset.backgroundColor = colors.dangerColor; + dataset.borderColor = colors.dangerColor; + } else { + dataset.backgroundColor = colors.primaryColor; + dataset.borderColor = colors.primaryColor; + // El aro del punto va del color del fondo, que cambia + // con el tema: sin esto se queda con el color anterior + // y el punto deja de recortarse. + dataset.pointBackgroundColor = colors.primaryColor; + dataset.pointBorderColor = colors.surfaceColor; + } + }); + applyCartesianColors(chart, colors); + } + + function render(mode) { + const colors = getChartColors(); + + // Cambiar de modo obliga a destruir y crear de nuevo. Mutar + // chart.config.type o chart.options.scales sobre un gráfico ya + // creado es lo que provocaba "t.startsWith is not a function". + if (balanceChart) { + balanceChart.destroy(); + } + + balanceChart = new Chart(canvas, { + type: mode === 'lines' ? 'line' : 'bar', + data: { + labels: labels, + datasets: datasetsFor(mode, colors) + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + grid: { color: colors.gridColor }, + ticks: { color: colors.tickColor } + }, + y: { + beginAtZero: true, + grid: { color: colors.gridColor }, + ticks: { color: colors.tickColor } + } + }, + plugins: { + legend: { + labels: { + color: colors.tickColor, + // `order` también ordena la leyenda, y con el + // neto en 0 saldría listado primero. Se fija + // por índice del dataset para que se lea + // Ingresos, Gastos y el neto al final, que es + // el derivado. Explícito y no por defecto: + // así no depende de cómo ordene Chart.js. + sort: function (a, b) { + return a.datasetIndex - b.datasetIndex; + } + } + } + } + } + }); + + // Imprescindible tras recrear: si no se vuelve a registrar, el + // gráfico queda fuera del tema y el modo oscuro deja de + // aplicársele en cuanto el usuario cambia de modo. + registerThemedChart(balanceChart, applyBalanceColors); + } + + const initialMode = storedMode(); + modeSelect.value = initialMode; + render(initialMode); + + modeSelect.addEventListener('change', function () { + rememberMode(modeSelect.value); + render(modeSelect.value); + }); + })(); + const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]; {% for acc in accounts_charts %} -- 2.45.2 From 20fb21721a2d92184b2d22ab4d77147e72711f67 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 02:34:45 +0200 Subject: [PATCH 4/4] Modified gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 841b644..2bc88cc 100644 --- a/.gitignore +++ b/.gitignore @@ -165,4 +165,5 @@ staticfiles/ *.bak # graphify -graphify-out/ \ No newline at end of file +graphify-out/ +.gstack/ -- 2.45.2