Add income vs expense chart with selectable modes

This commit is contained in:
JKuijperM 2026-10-14 02:34:32 +02:00
parent 27703bd47c
commit 3eb36a1226
3 changed files with 255 additions and 2 deletions

View File

@ -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 <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;
}
.chart-box {
position: relative;
height: 320px;

View File

@ -13,6 +13,15 @@
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
// Verde ingreso / rojo gasto, los mismos acentos que usan los
// importes de las tablas, para que el gráfico no invente un código
// de color propio.
successColor: styles.getPropertyValue('--color-success-accent').trim() || '#1e7e34',
dangerColor: styles.getPropertyValue('--color-danger-accent').trim() || '#b91c1c',
// Para aros y contornos que tienen que leerse encima de otra serie.
// Cambia con el tema, así que el punto se recorta igual en claro
// que en oscuro.
surfaceColor: styles.getPropertyValue('--color-surface').trim() || '#ffffff',
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
};
}
@ -57,6 +66,17 @@
var registeredCharts = [];
function registerThemedChart(chart, applyColors) {
// Un gráfico que se recrea (destroy() + new Chart, que es lo que exige
// cambiar de tipo) vuelve a pasar por aquí. El registro solo acumulaba,
// así que la entrada vieja se quedaba apuntando a un gráfico destruido y
// refreshThemedCharts() petaba al llamarle update(), dejando sin tema a
// todos los gráficos registrados por detrás. Se descartan las entradas
// del mismo canvas y las de gráficos ya destruidos: destroy() deja
// chart.canvas en null.
registeredCharts = registeredCharts.filter(function (entry) {
return entry.chart.canvas && entry.chart.canvas !== chart.canvas;
});
registeredCharts.push({ chart: chart, applyColors: applyColors });
}

View File

@ -200,14 +200,35 @@
<div class="charts-container">
<section>
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
<div class="chart-header">
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
</div>
<div class="chart-box">
<canvas id="mainChart"></canvas>
</div>
</section>
<section>
<h2>Distribución por Categorías</h2>
<div class="chart-header">
<h2>Ingresos vs gastos ({% if chart_type == 'day' %}Por día{% else %}Por meses{% endif %})</h2>
<div class="filters-main">
<label class="sr-only" for="balanceChartMode">Modo del gráfico</label>
<select id="balanceChartMode">
<option value="bars">Barras agrupadas</option>
<option value="lines">Dos líneas</option>
<option value="net">Barras + neto</option>
</select>
</div>
</div>
<div class="chart-box">
<canvas id="balanceChart"></canvas>
</div>
</section>
<section>
<div class="chart-header">
<h2>Distribución por Categorías</h2>
</div>
<div class="chart-box">
<canvas id="categoryChart"></canvas>
</div>
@ -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 %}