Aplica el rango de fechas al dashboard

Un rango que solo funciona en el listado obliga a cambiar de pantalla para
responder "cuanto gaste en ese viaje". El dashboard acepta ahora los mismos
date_from y date_to, con la misma precedencia sobre ano y mes.

Dos decisiones que no son obvias:

La comparativa se desactiva cuando hay rango. Un rango arbitrario no tiene un
"periodo anterior" evidente, y elegir uno por el usuario daria un porcentaje
que parece significativo sin serlo. Se distingue entre pedirla y poder
aplicarla (compare_requested / compare_enabled) para poder avisar de que se
ha ignorado en vez de desmarcar la casilla en silencio.

El eje del grafico lo decide la longitud del rango, no el mes: por debajo de
62 dias se agrupa por dia y por encima por mes, que es donde un grafico
diario deja de leerse. El eje se rellena con ceros en los huecos, igual que
ya hacia el resto, para que un periodo sin gastos no colapse la escala. La
agrupacion diaria usa el propio campo date y no TruncDate, porque date ya es
un DateField y TruncDate le aplicaria una conversion de zona horaria que en
SQLite revienta si no esta instalada la base de datos de husos.

La tarjeta de proyeccion a fin de mes se oculta con rango activo: proyectar a
fin de mes desde un periodo arbitrario no significa nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-16 08:32:43 +02:00
parent c395c638e9
commit 836f3e8524
2 changed files with 104 additions and 11 deletions

View File

@ -16,7 +16,10 @@
</h1> </h1>
<p class="muted"> <p class="muted">
{% if selected_month %} {% if range_active %}
Del {{ date_from|date:"d/m/Y"|default:"principio" }}
al {{ date_to|date:"d/m/Y"|default:"hoy" }}
{% elif selected_month %}
{{ selected_month }}/{{ selected_year}} {{ selected_month }}/{{ selected_year}}
{% else %} {% else %}
Año {{ selected_year }} Año {{ selected_year }}
@ -66,6 +69,8 @@
{% endfor %} {% endfor %}
</select> </select>
{% include "expenses/_date_range.html" %}
{% if not period %} {% if not period %}
<label class="sr-only" for="yearSelect">Año</label> <label class="sr-only" for="yearSelect">Año</label>
<select name="year" id="yearSelect"> <select name="year" id="yearSelect">
@ -94,6 +99,15 @@
<button type="submit" class="btn btn-primary">Aplicar Filtros</button> <button type="submit" class="btn btn-primary">Aplicar Filtros</button>
</form> </form>
{% if range_active %}
<p class="range-notice">
Mostrando del {{ date_from|date:"d/m/Y"|default:"principio" }} al
{{ date_to|date:"d/m/Y"|default:"hoy" }}. Los filtros de año, mes
{% if compare_suppressed %}y la comparativa {% endif %}no se aplican.
<a href="{% url 'dashboard' %}">Quitar el rango</a>
</p>
{% endif %}
</section> </section>
<!-- ========================= --> <!-- ========================= -->
@ -126,7 +140,7 @@
<span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span> <span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span>
</div> </div>
{% if selected_month == today.month and selected_year == today.year %} {% if selected_month == today.month and selected_year == today.year and not range_active %}
<div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);"> <div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);">
<span class="kpi-label">Proyección fin de mes</span> <span class="kpi-label">Proyección fin de mes</span>
<span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span> <span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span>

View File

@ -1,6 +1,6 @@
import logging import logging
import calendar import calendar
from datetime import date, datetime from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
@ -15,8 +15,14 @@ from .forms import (
) )
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Sum, Count, ProtectedError from django.db.models import Sum, Count, Min, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower from django.db.models.functions import (
ExtractMonth,
ExtractYear,
ExtractDay,
Lower,
TruncMonth,
)
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
@ -544,11 +550,19 @@ def dashboard(request):
today = date.today() today = date.today()
account_id = _get_int(request.GET.get("account")) account_id = _get_int(request.GET.get("account"))
compare_enabled = request.GET.get("compare") == "1" compare_requested = request.GET.get("compare") == "1"
# Time presets # Time presets
period, selected_year, selected_month = _resolve_period(request, today) period, selected_year, selected_month = _resolve_period(request, today)
# Rango de fechas libre. Si está activo manda sobre año y mes, y además
# desactiva la comparativa: no hay un "periodo anterior" obvio de un rango
# arbitrario. compare_suppressed sirve para decírselo al usuario.
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
compare_enabled = compare_requested and not range_active
compare_suppressed = compare_requested and range_active
# Accounts # Accounts
accounts = list(Account.objects.filter(owner=request.user, active=True)) accounts = list(Account.objects.filter(owner=request.user, active=True))
selected_account_obj = None selected_account_obj = None
@ -571,10 +585,18 @@ def dashboard(request):
if account_id: if account_id:
expenses = expenses.filter(account_id=account_id) expenses = expenses.filter(account_id=account_id)
if range_active:
expenses_filtered = expenses
if date_from:
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
if date_to:
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
else:
expenses_filtered = expenses.filter(date__year=selected_year) expenses_filtered = expenses.filter(date__year=selected_year)
if selected_month: if selected_month:
expenses_filtered = expenses_filtered.filter(date__month=selected_month) expenses_filtered = expenses_filtered.filter(date__month=selected_month)
# Basic KPIs # Basic KPIs
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0 total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
expense_count = expenses_filtered.count() expense_count = expenses_filtered.count()
@ -605,7 +627,60 @@ def dashboard(request):
#Graphic #Graphic
daily_average = 0 daily_average = 0
projected_end_of_month = 0 projected_end_of_month = 0
if selected_month: 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"]
span_to = date_to or today
if span_from is None:
# Sin gastos y sin fecha inicial no hay longitud que medir.
chart_labels = []
chart_totals = []
chart_type = "month"
else:
span_days = (span_to - span_from).days + 1
daily_average = total_amount / span_days if span_days > 0 else 0
# projected_end_of_month se queda en 0: proyectar a fin de mes desde
# 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]
chart_type = "day"
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 = []
cursor = (span_from.year, span_from.month)
last = (span_to.year, span_to.month)
while cursor <= last:
axis.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_type = "month"
elif selected_month:
by_day_qs = ( by_day_qs = (
expenses_filtered.annotate(day=ExtractDay("date")) expenses_filtered.annotate(day=ExtractDay("date"))
.values("day") .values("day")
@ -718,6 +793,10 @@ def dashboard(request):
"months": list(range(1, 13)), "months": list(range(1, 13)),
"selected_year": selected_year, "selected_year": selected_year,
"selected_month": selected_month, "selected_month": selected_month,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"compare_suppressed": compare_suppressed,
"kpi_total": total_amount, "kpi_total": total_amount,
"kpi_count": expense_count, "kpi_count": expense_count,
"kpi_categories": category_count, "kpi_categories": category_count,