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

@@ -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 && (