From ce7073cf24d7d7df20a843d7a7e8f68502a9da24 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 14:06:58 -0600 Subject: [PATCH] Analytics: date-range filter + fix category endpoint Decimal crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/v1/endpoints/analytics.py | 21 +++++- backend/tests/test_analytics.py | 62 +++++++++++++++++ frontend/src/lib/api-types.gen.ts | 4 ++ frontend/src/pages/Analytics.tsx | 85 ++++++++++++++++++++--- 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_analytics.py diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index d5bcf58..512cd0a 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -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) diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py new file mode 100644 index 0000000..26b7b7e --- /dev/null +++ b/backend/tests/test_analytics.py @@ -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 == [] diff --git a/frontend/src/lib/api-types.gen.ts b/frontend/src/lib/api-types.gen.ts index 4cb9f40..2a9d6fb 100644 --- a/frontend/src/lib/api-types.gen.ts +++ b/frontend/src/lib/api-types.gen.ts @@ -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; diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index f258f6a..4a0ff4e 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -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 = cycle - ? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) } - : {}; + const [range, setRange] = useState(); + + 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 = 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('/analytics/by-category', { params: cycleParams, signal }) + .get('/analytics/by-category', { params: periodParams, signal }) .then((r) => r.data), placeholderData: (prev) => prev, }); @@ -130,10 +149,10 @@ export default function Analytics() { api.get('/analytics/monthly-trend', { signal }).then((r) => r.data), }); const dailyQ = useQuery({ - queryKey: ['analytics', 'daily', cycle], + queryKey: ['analytics', 'daily', cycle, range], queryFn: ({ signal }) => api - .get('/analytics/daily-spending', { params: cycleParams, signal }) + .get('/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={} + actions={ + <> + + + } + > + + {rangeActive + ? `${range!.from!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })} – ${range!.to!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })}` + : 'Rango'} + + + { + setRange(r); + if (r?.from && r?.to) setCycle(null); + }} + numberOfMonths={2} + /> + + + {rangeActive && ( + + )} + { + setCycle(c); + if (c) setRange(undefined); + }} + /> + + } /> {anyError && (