Analytics: date-range filter + fix category endpoint Decimal crash

by-category and daily-spending accept start_date/end_date (range wins
over cycle); Analytics gets a two-month calendar range picker beside
the cycle selector. Fixes a latent float/Decimal TypeError that 500'd
spending_by_category whenever it had data — the category donut had
never rendered. Regression + range tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 14:06:58 -06:00
parent 5033348320
commit ce7073cf24
4 changed files with 160 additions and 12 deletions

View File

@@ -43,6 +43,8 @@ class DailySpending(BaseModel):
def spending_by_category(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
@@ -61,13 +63,19 @@ def spending_by_category(
.group_by(Transaction.category_id)
)
if cycle_year and cycle_month:
# Arbitrary date range takes precedence over the cycle filter.
if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(query).all()
grand_total = sum(r[1] for r in rows) or 1
grand_total = float(sum(float(r[1]) for r in rows)) or 1.0
results = []
for category_id, total, count in rows:
@@ -165,6 +173,8 @@ def monthly_trend(
def daily_spending(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
@@ -187,7 +197,12 @@ def daily_spending(
.order_by(func.date(Transaction.date))
)
if cycle_year and cycle_month:
if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)

View File

@@ -0,0 +1,62 @@
"""Analytics endpoints: category aggregation (Decimal regression) and the
arbitrary date-range filters added for the Analytics range picker."""
from datetime import datetime
from decimal import Decimal
from app.api.v1.endpoints.analytics import daily_spending, spending_by_category
from app.models.models import Category, Transaction
from tests.test_installments import make_cc_tx
def seed(session):
cat = Category(name="Groceries")
session.add(cat)
session.commit()
make_cc_tx(session, merchant="A", amount="1000.50", reference="a1",
date=datetime(2026, 6, 2), category_id=cat.id)
make_cc_tx(session, merchant="B", amount="2000.00", reference="b1",
date=datetime(2026, 6, 20))
return cat
class TestSpendingByCategory:
def test_decimal_sum_does_not_crash_and_percentages_add_up(self, session):
"""Regression: Decimal grand total vs float row total raised
TypeError, 500ing the endpoint whenever it had data."""
seed(session)
rows = spending_by_category(session=session, _user="t")
assert {r.category_name for r in rows} == {"Groceries", "Uncategorized"}
assert sum(r.percentage for r in rows) == 100.0
def test_date_range_overrides_cycle(self, session):
seed(session)
rows = spending_by_category(
start_date="2026-06-01",
end_date="2026-06-10",
cycle_year=2026,
cycle_month=1, # would match nothing; range must win
session=session,
_user="t",
)
assert len(rows) == 1
assert rows[0].category_name == "Groceries"
assert rows[0].total == 1000.50
class TestDailySpending:
def test_date_range_filter(self, session):
seed(session)
rows = daily_spending(
start_date="2026-06-15", end_date="2026-07-01",
session=session, _user="t",
)
assert [r.date for r in rows] == ["2026-06-20"]
assert rows[0].total == 2000.00
def test_future_cuotas_excluded(self, session):
make_cc_tx(session, merchant="FUTURE", amount="500.00",
reference="f1", date=datetime(2030, 1, 19))
rows = daily_spending(session=session, _user="t")
assert rows == []

View File

@@ -2419,6 +2419,8 @@ export interface operations {
query?: {
cycle_year?: number | null;
cycle_month?: number | null;
start_date?: string | null;
end_date?: string | null;
};
header?: {
authorization?: string | null;
@@ -2455,6 +2457,8 @@ export interface operations {
query?: {
cycle_year?: number | null;
cycle_month?: number | null;
start_date?: string | null;
end_date?: string | null;
};
header?: {
authorization?: string | null;

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { type DateRange } from 'react-day-picker';
import {
PieChart,
Pie,
@@ -11,7 +12,7 @@ import {
LineChart,
Line,
} from 'recharts';
import { BarChart3, ChartPie } from 'lucide-react';
import { BarChart3, CalendarRange, ChartPie, X } from 'lucide-react';
import api, { getRateHistory } from '@/lib/api';
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
@@ -19,6 +20,13 @@ import ErrorState from '@/components/ErrorState';
import BillingCycleSelector from '@/components/BillingCycleSelector';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Empty,
EmptyDescription,
@@ -110,15 +118,26 @@ const dailyChartConfig = {
export default function Analytics() {
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const cycleParams: Record<string, string> = cycle
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
: {};
const [range, setRange] = useState<DateRange | undefined>();
const toISODate = (d: Date) => d.toISOString().slice(0, 10);
const nextDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
const rangeActive = !!(range?.from && range?.to);
// Range takes precedence over the billing-cycle filter (end is exclusive).
const periodParams: Record<string, string> = rangeActive
? {
start_date: toISODate(range!.from!),
end_date: toISODate(nextDay(range!.to!)),
}
: cycle
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
: {};
const byCategoryQ = useQuery({
queryKey: ['analytics', 'by-category', cycle],
queryKey: ['analytics', 'by-category', cycle, range],
queryFn: ({ signal }) =>
api
.get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal })
.get<CategorySpending[]>('/analytics/by-category', { params: periodParams, signal })
.then((r) => r.data),
placeholderData: (prev) => prev,
});
@@ -130,10 +149,10 @@ export default function Analytics() {
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
});
const dailyQ = useQuery({
queryKey: ['analytics', 'daily', cycle],
queryKey: ['analytics', 'daily', cycle, range],
queryFn: ({ signal }) =>
api
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal })
.get<DailySpending[]>('/analytics/daily-spending', { params: periodParams, signal })
.then((r) => r.data),
placeholderData: (prev) => prev,
});
@@ -173,7 +192,55 @@ export default function Analytics() {
icon={BarChart3}
title="Analytics"
subtitle="Desglose y tendencias de gasto"
actions={<BillingCycleSelector value={cycle} onChange={setCycle} />}
actions={
<>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className={rangeActive ? 'text-foreground' : 'text-muted-foreground'}
/>
}
>
<CalendarRange className="w-4 h-4 mr-2" aria-hidden />
{rangeActive
? `${range!.from!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })} ${range!.to!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })}`
: 'Rango'}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
selected={range}
onSelect={(r) => {
setRange(r);
if (r?.from && r?.to) setCycle(null);
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
{rangeActive && (
<Button
variant="ghost"
size="icon"
onClick={() => setRange(undefined)}
title="Quitar rango"
aria-label="Quitar rango de fechas"
>
<X className="w-4 h-4" aria-hidden />
</Button>
)}
<BillingCycleSelector
value={cycle}
onChange={(c) => {
setCycle(c);
if (c) setRange(undefined);
}}
/>
</>
}
/>
{anyError && (