Add expense type breakdown chart to dashboard

This commit is contained in:
JKuijperM 2026-10-18 15:59:35 +02:00
parent 9207580f8a
commit 40b0c7ed7b
5 changed files with 459 additions and 16 deletions

View File

@ -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 <select> en cada navegador, que es justo lo que
haría que cuadrara en uno y no en otro. */
/* El alto del control se fija igual que la línea del título. Así la cabecera
mide siempre el min-height en todas las tarjetas y la alineación no depende
de cuánto mida un <select> 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 <select> de al lado; el pulsado lo marca aria-pressed. */
.icon-toggle {
display: inline-flex;
box-sizing: border-box;
height: 2.25rem;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
}
.icon-toggle button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 100%;
padding: 0;
border: none;
color: var(--color-text-muted);
background: var(--color-surface);
cursor: pointer;
}
.icon-toggle button + button {
border-left: 1px solid var(--color-border);
}
.icon-toggle button[aria-pressed="true"] {
color: var(--color-on-primary);
background: var(--color-primary);
}
.icon-toggle button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
.chart-box {
position: relative;
height: 320px;

View File

@ -235,7 +235,60 @@
</section>
<section>
<h2>Gastos Recientes</h2>
<div class="chart-header">
<h2>Distribución por tipo de gasto</h2>
{% if expense_type_classified_count >= 2 and kpi_total %}
<div class="filters-main">
<label class="sr-only" for="expenseTypeGrouping">Agrupación</label>
<select id="expenseTypeGrouping">
<option value="types">Cuatro tipos</option>
<option value="fixed">Fijo / variable</option>
<option value="essential">Esencial / prescindible</option>
</select>
<div class="icon-toggle" role="group" aria-label="Forma del gráfico">
<button type="button" data-shape="doughnut" aria-label="Donut" aria-pressed="true">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="4"/>
</svg>
</button>
<button type="button" data-shape="bar" aria-label="Barras horizontales" aria-pressed="false">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<rect x="3" y="4" width="16" height="4" fill="currentColor"/>
<rect x="3" y="10" width="10" height="4" fill="currentColor"/>
<rect x="3" y="16" width="6" height="4" fill="currentColor"/>
</svg>
</button>
<button type="button" data-shape="column" aria-label="Barras verticales" aria-pressed="false">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<rect x="4" y="5" width="4" height="16" fill="currentColor"/>
<rect x="10" y="11" width="4" height="10" fill="currentColor"/>
<rect x="16" y="15" width="4" height="6" fill="currentColor"/>
</svg>
</button>
</div>
</div>
{% endif %}
</div>
{% if expense_type_classified_count < 2 %}
<div class="empty-state">
<p>Clasifica al menos dos categorías para ver este desglose</p>
<a href="{% url 'category_list' %}">Ir a categorías</a>
</div>
{% elif kpi_total %}
<div class="chart-box">
<canvas id="expenseTypeChart"></canvas>
</div>
{% else %}
<p class="empty-state">No hay gastos en este periodo</p>
{% endif %}
</section>
</div>
<!-- Fuera de .charts-container: es una tabla, no tiene eje que alinear con
los gráficos y encajada entre dos canvas había que desplazarla para
ver sus columnas. -->
<section>
<h2>Gastos Recientes</h2>
<div class="table-wrap">
<table class="table">
<thead>
@ -267,9 +320,8 @@
</tbody>
</table>
</div>
</section>
</div>
</section>
<div style="clear: both; margin-top: 2rem;"></div>
<script>
@ -302,6 +354,119 @@
registerThemedChart(categoryChart, function (chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
});
// Sin gasto en el periodo, o con menos de dos categorías clasificadas,
// la plantilla no pinta el canvas.
const expenseTypeCanvas = document.getElementById('expenseTypeChart');
if (expenseTypeCanvas) {
// Orden fijo del servidor: fijo esencial, fijo prescindible,
// variable esencial, variable prescindible y "Sin clasificar".
const typeLabels = [{% for row in expense_type_breakdown %}"{{ row.label }}",{% endfor %}];
const typeTotals = [{% for row in expense_type_breakdown %}{{ row.total|unlocalize }},{% endfor %}];
const t = typeTotals;
// Las agrupaciones de dos bloques se suman aquí con los cinco
// importes que ya llegan: cambiar de vista no pide nada al servidor.
// "Sin clasificar" se queda siempre aparte, nunca se reparte.
const GROUPINGS = {
types: { labels: typeLabels, data: typeTotals },
fixed: { labels: ['Fijo', 'Variable', typeLabels[4]], data: [t[0] + t[1], t[2] + t[3], t[4]] },
essential: { labels: ['Esencial', 'Prescindible', typeLabels[4]], data: [t[0] + t[2], t[1] + t[3], t[4]] }
};
const groupingSelect = document.getElementById('expenseTypeGrouping');
const shapeButtons = document.querySelectorAll('[data-shape]');
let grouping = 'types';
let shape = 'doughnut';
let expenseTypeChart = null;
// Un color por bloque y "Sin clasificar", que va el último, en gris.
function blockColors(count, colors) {
return colors.palette.slice(0, count - 1).concat(colors.tickColor);
}
function applyExpenseTypeColors(chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
chart.data.datasets[0].backgroundColor = blockColors(chart.data.labels.length, colors);
if (shape !== 'doughnut') {
applyCartesianColors(chart, colors);
}
}
function expenseTypeConfig(colors) {
const group = GROUPINGS[grouping];
const legend = { labels: { color: colors.tickColor } };
const axis = {
grid: { color: colors.gridColor },
ticks: { color: colors.tickColor }
};
if (shape === 'doughnut') {
legend.position = 'right';
return {
type: 'doughnut',
data: {
labels: group.labels,
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
},
options: { plugins: { legend: legend } }
};
}
// Barras horizontales ('bar') o verticales ('column'), con los
// importes en el eje de valores. Cada barra lleva su etiqueta,
// así que la leyenda sobra.
const valueAxis = shape === 'bar' ? 'x' : 'y';
const categoryAxis = shape === 'bar' ? 'y' : 'x';
const scales = {};
scales[valueAxis] = Object.assign({ beginAtZero: true }, axis);
scales[categoryAxis] = axis;
legend.display = false;
return {
type: 'bar',
data: {
labels: group.labels,
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
},
options: {
indexAxis: categoryAxis,
scales: scales,
plugins: { legend: legend }
}
};
}
function renderExpenseTypeChart() {
const config = expenseTypeConfig(getChartColors());
config.options.responsive = true;
config.options.maintainAspectRatio = false;
// Cambiar de forma cambia el tipo de gráfico: hay que destruir y
// crear de nuevo, igual que en el de ingresos vs gastos.
if (expenseTypeChart) {
expenseTypeChart.destroy();
}
expenseTypeChart = new Chart(expenseTypeCanvas, config);
registerThemedChart(expenseTypeChart, applyExpenseTypeColors);
}
groupingSelect.addEventListener('change', function () {
grouping = groupingSelect.value;
renderExpenseTypeChart();
});
shapeButtons.forEach(function (button) {
button.addEventListener('click', function () {
shape = button.dataset.shape;
shapeButtons.forEach(function (other) {
other.setAttribute('aria-pressed', other === button ? 'true' : 'false');
});
renderExpenseTypeChart();
});
});
renderExpenseTypeChart();
}
</script>
<h2>Evolución anual por cuenta ({{ selected_year }})</h2>

View File

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

View File

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

View File

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