Compare commits

..

No commits in common. "b4508a13db8531e78aa4d20cf9a1670b07266c7e" and "e5aa66369a92e1ed0fb0058aa1fabb2aa3178a33" have entirely different histories.

9 changed files with 112 additions and 267 deletions

View File

@ -97,31 +97,6 @@ a.danger:visited,
color: var(--color-danger-accent); color: var(--color-danger-accent);
} }
/* Escala tipográfica. Los márgenes van en rem a propósito: los del navegador
van en em y escalan con el tamaño de fuente, dejando huecos irregulares
entre secciones. */
h1 {
font-size: 1.625rem;
font-weight: 600;
line-height: 1.2;
margin: 0 0 1rem;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.25;
margin: 1.5rem 0 0.75rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
margin: 1rem 0 0.5rem;
}
/* ========================= */ /* ========================= */
/* Topbar / navigation */ /* Topbar / navigation */
/* ========================= */ /* ========================= */
@ -298,7 +273,7 @@ h3 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.dashboard-context h1 { .dashboard-context h2 {
margin: 0; margin: 0;
} }
@ -923,7 +898,7 @@ tr:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.settings-card h2 { .settings-card h3 {
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
} }

View File

@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Editar categoría</h1> <h2>Editar categoría</h2>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}

View File

@ -8,7 +8,7 @@
<h1>Mis categorías</h1> <h1>Mis categorías</h1>
<h2>Nueva categoría</h2> <h3>Nueva categoría</h3>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}
{% include "expenses/_form_fields.html" %} {% include "expenses/_form_fields.html" %}
@ -17,7 +17,7 @@
<hr> <hr>
<h2>Listado</h2> <h3>Listado</h3>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>

View File

@ -7,13 +7,13 @@
{% block content %} {% block content %}
<section class="dashboard-context"> <section class="dashboard-context">
<h1> <h2>
{% if selected_account_obj %} {% if selected_account_obj %}
{{ selected_account_obj.name }} {{ selected_account_obj.name }}
{% else %} {% else %}
Todas las cuentas Todas las cuentas
{% endif %} {% endif %}
</h1> </h2>
<p class="muted"> <p class="muted">
{% if selected_month %} {% if selected_month %}
@ -95,6 +95,34 @@
<button type="submit" class="btn btn-primary">Aplicar Filtros</button> <button type="submit" class="btn btn-primary">Aplicar Filtros</button>
</form> </form>
</section> </section>
<script>
const yearSelect = document.getElementById('yearSelect');
const monthSelect = document.getElementById('monthSelect');
const hiddenPeriod = document.getElementById('hiddenPeriod');
function clearPeriodPreset() {
if (hiddenPeriod) {
hiddenPeriod.value = ""
}
document.querySelectorAll('.preset').forEach(btn => btn.classList.remove('active'));
}
</script>
<!-- <script>
document.querySelectorAll('.dashboard-presets a').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const period = link.dataset.period;
const isActive = link.classList.contains('active');
if (isActive) {
window.location.href = link.href;
} else {
window.location.href = `${link.href}?period=${period}`
}
});
});
</script> -->
<!-- ========================= --> <!-- ========================= -->
<!-- KPIs --> <!-- KPIs -->
@ -137,7 +165,7 @@
{% if compare_enabled %} {% if compare_enabled %}
<section class="comparison"> <section class="comparison">
<h2>Resumen comparativo</h2> <h3>Resumen comparativo</h3>
<p> <p>
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br> Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br> Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
@ -150,7 +178,7 @@
</small> </small>
</p> </p>
<h2>Desglose de cambios por categoría</h2> <h3>Desglose de cambios por categoría</h3>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@ -184,21 +212,21 @@
<div class="charts-container"> <div class="charts-container">
<section> <section>
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2> <h3>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h3>
<div class="chart-box"> <div class="chart-box">
<canvas id="mainChart"></canvas> <canvas id="mainChart"></canvas>
</div> </div>
</section> </section>
<section> <section>
<h2>Distribución por Categorías</h2> <h3>Distribución por Categorías</h3>
<div class="chart-box"> <div class="chart-box">
<canvas id="categoryChart"></canvas> <canvas id="categoryChart"></canvas>
</div> </div>
</section> </section>
<section> <section>
<h2>Gastos Recientes</h2> <h3>Gastos Recientes</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
@ -262,11 +290,11 @@
}); });
</script> </script>
<h2>Evolución anual por cuenta ({{ selected_year }})</h2> <h3>Evolución anual por cuenta ({{ selected_year }})</h3>
<div class="dashboard-grid"> <div class="dashboard-grid">
{% for acc in accounts_charts %} {% for acc in accounts_charts %}
<div class="card card-chart"> <div class="card card-chart">
<h3>{{ acc.name }}</h3> <h4>{{ acc.name }}</h4>
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p> <p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p>
<div class="canvas-wrapper"> <div class="canvas-wrapper">
<canvas id="accountChart{{ acc.id }}"></canvas> <canvas id="accountChart{{ acc.id }}"></canvas>
@ -365,11 +393,28 @@
</script> </script>
{% if compare_enabled %}
<section class="comparison">
<h3>Comparativa</h3>
<p>
Diferencia:
<strong class="{% if kpi_trend == 'up' %}positive{% endif %}">
{% if kpi_trend == "up" %}+{% endif %}
{{ kpi_difference_abs|floatformat:2 }} €
</strong>
{% if kpi_percentage %}
({{ kpi_percentage|floatformat:1 }}%)
{% endif %}
</p>
</section>
{% endif %}
<!-- ========================= --> <!-- ========================= -->
<!-- Goals --> <!-- Goals -->
<!-- ========================= --> <!-- ========================= -->
<h2>Objetivos</h2> <h3>Objetivos</h3>
{% if goals %} {% if goals %}
<div class="goals-widget"> <div class="goals-widget">
{% for goal in goals %} {% for goal in goals %}

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h1>Iniciar sesión</h1> <h2>Iniciar sesión</h2>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h1>Recuperar contraseña</h1> <h2>Recuperar contraseña</h2>
<p> <p>
Esta aplicación actualmente no envía correos de recuperación automáticos. Esta aplicación actualmente no envía correos de recuperación automáticos.

View File

@ -5,23 +5,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Configuración</h1> <h2>Configuración</h2>
<p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p> <p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p>
<div class="settings-grid"> <div class="settings-grid">
<a class="settings-card" href="{% url 'category_list' %}"> <a class="settings-card" href="{% url 'category_list' %}">
<h2>Categorías</h2> <h3>Categorías</h3>
<p>Organiza tus gastos por tipo.</p> <p>Organiza tus gastos por tipo.</p>
</a> </a>
<a class="settings-card" href="{% url 'tag_list' %}"> <a class="settings-card" href="{% url 'tag_list' %}">
<h2>Etiquetas</h2> <h3>Etiquetas</h3>
<p>Añade etiquetas libres a tus gastos.</p> <p>Añade etiquetas libres a tus gastos.</p>
</a> </a>
<a class="settings-card" href="{% url 'goal_list' %}"> <a class="settings-card" href="{% url 'goal_list' %}">
<h2>Objetivos</h2> <h3>Objetivos</h3>
<p>Define y controla tus metas de gasto.</p> <p>Define y controla tus metas de gasto.</p>
</a> </a>
</div> </div>

View File

@ -2,7 +2,7 @@ import pytest
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.urls import reverse from django.urls import reverse
from expenses.models import Account, Expense, Category, Income from expenses.models import Expense, Category
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -80,146 +80,3 @@ def test_dashboard_filters_by_year(auth_client, user, account, category):
assert len(chart_data) == 12 assert len(chart_data) == 12
assert chart_data[4] == 20.0 # May = month 5 -> index 4 assert chart_data[4] == 20.0 # May = month 5 -> index 4
assert sum(chart_data) == 20.0 # only the 2024 expense contributes assert sum(chart_data) == 20.0 # only the 2024 expense contributes
def test_dashboard_preset_this_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_month'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] == today.month
def test_dashboard_preset_last_month(auth_client):
today = date.today()
expected_month = today.month - 1 or 12
expected_year = today.year if today.month > 1 else today.year - 1
response = auth_client.get(reverse('dashboard'), {'period': 'last_month'})
assert response.context['selected_year'] == expected_year
assert response.context['selected_month'] == expected_month
def test_dashboard_preset_this_year_has_no_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_year'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] is None
def test_dashboard_filters_by_account(auth_client, user, account, category):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("100"), active=True
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("10"), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=other, category=category,
amount=Decimal("25"), date=date(2024, 3, 1),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'account': other.id},
)
assert response.context['kpi_total'] == Decimal('25')
assert response.context['kpi_count'] == 1
assert response.context['kpi_balance'] == Decimal('75') # 100 - 25
def test_dashboard_without_compare_keeps_comparison_defaults(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['compare_enabled'] is False
assert response.context['kpi_previous_total'] == 0
assert response.context['kpi_difference'] == 0
assert response.context['kpi_percentage'] == 0
assert response.context['kpi_trend'] == 'equal'
assert response.context['category_comparison'] == []
def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account, category):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("20"), date=date(2024, 2, 15),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("30"), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'month': 3, 'compare': '1'},
)
assert response.context['compare_enabled'] is True
assert response.context['kpi_previous_total'] == Decimal('20')
assert response.context['kpi_difference'] == Decimal('10')
assert response.context['kpi_trend'] == 'up'
assert float(response.context['kpi_percentage']) == 50.0
rows = {row['category']: row for row in response.context['category_comparison']}
assert rows[category.name]['current'] == 30.0
assert rows[category.name]['previous'] == 20.0
assert rows[category.name]['difference'] == 10.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})
assert response.context['chart_type'] == 'day'
def test_dashboard_chart_type_is_month_for_the_whole_year(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['chart_type'] == 'month'
def test_dashboard_category_chart_keeps_only_top_ten(auth_client, user, account):
for i in range(12):
cat = Category.objects.create(name=f"Cat {i:02d}", owner=user)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal(str(i + 1)), date=date(2024, 6, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert len(response.context['by_category_chart']) == 10
assert len(list(response.context['by_category'])) == 12
def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user, account, category):
savings = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("500"), active=True
)
Account.objects.create(
owner=user, name="Cerrada", initial_balance=Decimal("999"), active=False
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("40"), date=date(2024, 4, 1),
)
Income.objects.create(
owner=user, account=savings, name="Nomina",
amount=Decimal("60"), date=date(2024, 4, 2),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
expected = sum(
acc.current_balance()
for acc in Account.objects.filter(owner=user, active=True)
)
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
assert response.context['kpi_balance'] == expected

View File

@ -112,67 +112,6 @@ def _account_balances(accounts):
return account_balances, negative_accounts, total_balance return account_balances, negative_accounts, total_balance
def _resolve_period(request, today):
"""Traduce los parámetros de la URL en el periodo mirado por el dashboard.
Devuelve (period, selected_year, selected_month), donde selected_month es
None cuando se está mirando el año completo."""
period = request.GET.get("period", "")
if period == "this_month":
return period, today.year, today.month
if period == "last_month":
selected_year, selected_month = sub_months(today.year, today.month, 1)
return period, selected_year, selected_month
if period == "this_year":
return period, today.year, None
selected_year = _get_int(request.GET.get("year")) or today.year
selected_month = _get_int(request.GET.get("month")) or None
return period, selected_year, selected_month
def _build_account_charts(accounts, balance_by_account, selected_year, today):
"""Serie mensual de saldo de cada cuenta para las gráficas del dashboard.
En el año en curso la serie se corta en el mes actual y ese último punto
se sustituye por el saldo real de la cuenta, que llega ya calculado en
balance_by_account para no repetir current_balance() por cuenta."""
accounts_charts = []
for acc in accounts:
try:
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
)
m_balance = [0] * 12
current_balance = balance_by_account[acc.id]
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(current_balance)
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": current_balance,
})
return accounts_charts
@login_required @login_required
def home(request): def home(request):
today = date.today() today = date.today()
@ -468,26 +407,33 @@ def expense_delete(request, pk):
def dashboard(request): def dashboard(request):
today = date.today() today = date.today()
period = request.GET.get("period", "")
account_id = _get_int(request.GET.get("account")) account_id = _get_int(request.GET.get("account"))
compare_enabled = request.GET.get("compare") == "1" compare_enabled = request.GET.get("compare") == "1"
# Time presets # Time presets
period, selected_year, selected_month = _resolve_period(request, today) if period == "this_month":
selected_year, selected_month = today.year, today.month
elif period == "last_month":
selected_year, selected_month = sub_months(today.year, today.month, 1)
elif period == "this_year":
selected_year, selected_month = today.year, None
else:
selected_year = _get_int(request.GET.get("year")) or _get_int(today.year)
selected_month = _get_int(request.GET.get("month"))
selected_month = _get_int(selected_month) if selected_month else None
# Accounts # Accounts
accounts = list(Account.objects.filter(owner=request.user, active=True)) accounts = Account.objects.filter(owner=request.user, active=True)
selected_account_obj = None selected_account_obj = None
if account_id: if account_id:
selected_account_obj = next((a for a in accounts if a.id == account_id), None) selected_account_obj = accounts.filter(id=account_id).first()
# Calculate the KPI # Calculate the KPI
account_balances, _, total_balance = _account_balances(accounts)
balance_by_account = {row["account"].id: row["balance"] for row in account_balances}
if selected_account_obj: if selected_account_obj:
kpi_balance = balance_by_account[selected_account_obj.id] kpi_balance = selected_account_obj.current_balance()
else: else:
kpi_balance = total_balance kpi_balance = sum(account.current_balance() for account in accounts)
# Filter by base expenses # Filter by base expenses
expenses = Expense.objects.filter(owner=request.user) expenses = Expense.objects.filter(owner=request.user)
@ -615,9 +561,32 @@ def dashboard(request):
}) })
# Anual evolution by accounts # Anual evolution by accounts
accounts_charts = _build_account_charts( accounts_charts = []
accounts, balance_by_account, selected_year, today for acc in accounts:
try:
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
) )
m_balance = [0] * 12
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(acc.current_balance())
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": acc.current_balance(),
})
# Goals # Goals
goals = Goal.objects.filter(owner=request.user) goals = Goal.objects.filter(owner=request.user)
@ -636,7 +605,6 @@ def dashboard(request):
"by_category_chart": by_category_chart, "by_category_chart": by_category_chart,
"chart_labels": chart_labels, "chart_labels": chart_labels,
"chart_data": chart_totals, "chart_data": chart_totals,
"chart_type": chart_type,
"year_list": year_list, "year_list": year_list,
"months": list(range(1, 13)), "months": list(range(1, 13)),
"selected_year": selected_year, "selected_year": selected_year,