From 3eb36a1226d4de6cb5835a8b344ff3d9edd1b5ee Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 02:34:32 +0200 Subject: [PATCH] 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 %}