mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +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(
|
def spending_by_category(
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -61,13 +63,19 @@ def spending_by_category(
|
|||||||
.group_by(Transaction.category_id)
|
.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)
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||||
|
|
||||||
rows = session.exec(query).all()
|
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 = []
|
results = []
|
||||||
for category_id, total, count in rows:
|
for category_id, total, count in rows:
|
||||||
@@ -165,6 +173,8 @@ def monthly_trend(
|
|||||||
def daily_spending(
|
def daily_spending(
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -187,7 +197,12 @@ def daily_spending(
|
|||||||
.order_by(func.date(Transaction.date))
|
.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)
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
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?: {
|
query?: {
|
||||||
cycle_year?: number | null;
|
cycle_year?: number | null;
|
||||||
cycle_month?: number | null;
|
cycle_month?: number | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
};
|
};
|
||||||
header?: {
|
header?: {
|
||||||
authorization?: string | null;
|
authorization?: string | null;
|
||||||
@@ -2455,6 +2457,8 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
cycle_year?: number | null;
|
cycle_year?: number | null;
|
||||||
cycle_month?: number | null;
|
cycle_month?: number | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
};
|
};
|
||||||
header?: {
|
header?: {
|
||||||
authorization?: string | null;
|
authorization?: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { type DateRange } from 'react-day-picker';
|
||||||
import {
|
import {
|
||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarChart3, ChartPie } from 'lucide-react';
|
import { BarChart3, CalendarRange, ChartPie, X } from 'lucide-react';
|
||||||
|
|
||||||
import api, { getRateHistory } from '@/lib/api';
|
import api, { getRateHistory } from '@/lib/api';
|
||||||
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
|
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
|
||||||
@@ -19,6 +20,13 @@ import ErrorState from '@/components/ErrorState';
|
|||||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@@ -110,15 +118,26 @@ const dailyChartConfig = {
|
|||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
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) }
|
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const byCategoryQ = useQuery({
|
const byCategoryQ = useQuery({
|
||||||
queryKey: ['analytics', 'by-category', cycle],
|
queryKey: ['analytics', 'by-category', cycle, range],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
api
|
api
|
||||||
.get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal })
|
.get<CategorySpending[]>('/analytics/by-category', { params: periodParams, signal })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
@@ -130,10 +149,10 @@ export default function Analytics() {
|
|||||||
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
|
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
|
||||||
});
|
});
|
||||||
const dailyQ = useQuery({
|
const dailyQ = useQuery({
|
||||||
queryKey: ['analytics', 'daily', cycle],
|
queryKey: ['analytics', 'daily', cycle, range],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
api
|
api
|
||||||
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal })
|
.get<DailySpending[]>('/analytics/daily-spending', { params: periodParams, signal })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
@@ -173,7 +192,55 @@ export default function Analytics() {
|
|||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
title="Analytics"
|
title="Analytics"
|
||||||
subtitle="Desglose y tendencias de gasto"
|
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 && (
|
{anyError && (
|
||||||
|
|||||||
Reference in New Issue
Block a user