mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
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:
@@ -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)
|
||||
|
||||
|
||||
62
backend/tests/test_analytics.py
Normal file
62
backend/tests/test_analytics.py
Normal 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 == []
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
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 && (
|
||||
|
||||
Reference in New Issue
Block a user