Compare commits

...

3 Commits

Author SHA1 Message Date
20fb21721a Modified gitignore
All checks were successful
Tests / CI / Run tests passed: 306
expenses_manager/pipeline/head This commit looks good
2026-10-14 02:34:45 +02:00
3eb36a1226 Add income vs expense chart with selectable modes 2026-10-14 02:34:32 +02:00
27703bd47c Add income vs expense series to dashboard 2026-10-14 02:34:31 +02:00
6 changed files with 586 additions and 55 deletions

3
.gitignore vendored
View File

@ -165,4 +165,5 @@ staticfiles/
*.bak
# graphify
graphify-out/
graphify-out/
.gstack/

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 %}

View File

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

View File

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