From 2cf19f1880662f1346528a65e5c2ada5285c3767 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 12:43:57 -0600 Subject: [PATCH] Analytics: validated palette, bars for daily spend, Empty states, es-CR Categories fold into 'Otros' past 5 slots (fixed hue order, never cycled); daily spending excludes future-dated Tasa Cero cuotas; rate chart gets a legend; PageHeader with the cycle selector as action. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/v1/endpoints/analytics.py | 4 + frontend/src/pages/Analytics.tsx | 213 +++++++++++++--------- 2 files changed, 129 insertions(+), 88 deletions(-) diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index 996017b..d5bcf58 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -10,6 +10,7 @@ from app.auth import get_current_user from app.db import get_session from app.models.models import Category, Transaction, TransactionType from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range +from app.timeutil import utcnow from app.services.exchange_rate import get_converted_amount_expr router = APIRouter(prefix="/analytics", tags=["analytics"]) @@ -178,6 +179,9 @@ def daily_spending( .where( Transaction.transaction_type == TransactionType.COMPRA, NOT_INSTALLMENT_ANCHOR, + # Tasa Cero generates future-dated cuotas; daily spending is + # about money already spent. + Transaction.date <= utcnow(), ) .group_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date)) diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 476d1d4..de9e6cd 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -11,14 +11,24 @@ import { LineChart, Line, } from 'recharts'; -import { BarChart3 } from 'lucide-react'; +import { BarChart3, ChartPie } from 'lucide-react'; import api, { getRateHistory } from '@/lib/api'; 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 { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; import { ChartContainer, + ChartLegend, + ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig, @@ -47,32 +57,68 @@ interface DailySpending { count: number; } -const COLORS = [ - '#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED', - '#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5', +/** Fixed categorical slots (validated palette). Never cycled: categories + * beyond the 5th fold into "Otros" (muted). */ +const SERIES_VARS = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', ]; +const OTROS_COLOR = 'var(--muted-foreground)'; +const MAX_SLICES = 5; function formatCRC(value: number) { return `₡${Math.round(value).toLocaleString('es-CR')}`; } +/** Top 5 categories keep their own slice; the rest aggregate into "Otros". */ +function foldCategories(cats: CategorySpending[]) { + if (cats.length <= MAX_SLICES) return cats; + const head = cats.slice(0, MAX_SLICES); + const rest = cats.slice(MAX_SLICES); + return [ + ...head, + { + category_id: null, + category_name: 'Otros', + total: rest.reduce((s, c) => s + c.total, 0), + count: rest.reduce((s, c) => s + c.count, 0), + percentage: +rest.reduce((s, c) => s + c.percentage, 0).toFixed(1), + }, + ]; +} + +function sliceColor(index: number, name: string) { + return name === 'Otros' ? OTROS_COLOR : SERIES_VARS[index % SERIES_VARS.length]; +} + +function ChartEmpty({ description }: { description: string }) { + return ( + + + + + + Sin datos + {description} + + + ); +} + const trendChartConfig = { - total_crc: { - label: 'Total CRC', - color: 'var(--chart-1)', - }, + total_crc: { label: 'Total CRC', color: 'var(--chart-1)' }, } satisfies ChartConfig; const rateChartConfig = { sell_rate: { label: 'Venta', color: 'var(--chart-1)' }, - buy_rate: { label: 'Compra', color: 'var(--chart-2)' }, + buy_rate: { label: 'Compra', color: 'var(--chart-5)' }, } satisfies ChartConfig; const dailyChartConfig = { - total: { - label: 'Gasto Diario', - color: 'var(--chart-2)', - }, + total: { label: 'Gasto diario', color: 'var(--chart-1)' }, } satisfies ChartConfig; export default function Analytics() { @@ -106,6 +152,7 @@ export default function Analytics() { }); const byCategory = byCategoryQ.data ?? []; + const folded = foldCategories(byCategory); const trend = trendQ.data ?? []; const daily = dailyQ.data ?? []; const ratesQ = useQuery({ @@ -125,27 +172,22 @@ export default function Analytics() { dailyQ.refetch(); }; - // Build dynamic chart config for pie chart - const pieChartConfig = byCategory.reduce((acc, cat, i) => { + const pieChartConfig = folded.reduce((acc, cat, i) => { acc[cat.category_name] = { label: cat.category_name, - color: COLORS[i % COLORS.length], + color: sliceColor(i, cat.category_name), }; return acc; }, {}); return (
-
-
-
- -

Analytics

-
-

Desglose y tendencias de gasto

-
- -
+ } + /> {anyError && ( - {/* Spending by Category - Donut */} + {/* Gasto por categoría — donut */} - - Spending by Category + + Gasto por categoría - {byCategory.length === 0 ? ( -
- Sin datos para este período -
+ {folded.length === 0 ? ( + ) : (
- {byCategory.map((_, i) => ( - + {folded.map((cat, i) => ( + ))} - {byCategory.slice(0, 10).map((cat, i) => ( + {folded.map((cat, i) => (
{cat.category_name} {cat.percentage}% @@ -214,26 +256,20 @@ export default function Analytics() { - {/* Monthly Trend - Bar */} + {/* Gasto mensual — barras por ciclo */} - - Monthly Spending (CRC) + + Gasto mensual por ciclo (CRC) {trend.length === 0 ? ( -
- No data -
+ ) : ( - + } /> - + )}
- {/* Daily Spending - Line */} + {/* Gasto diario — barras */} - - Daily Spending + + Gasto diario {daily.length === 0 ? ( -
- Sin datos para este período -
+ ) : ( - + { - const d = new Date(v); - return `${d.getMonth() + 1}/${d.getDate()}`; - }} + minTickGap={24} + tickFormatter={(v) => + new Date(v).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' }) + } /> } /> - - + )}
- {/* Top categories summary */} + {/* Categorías principales */} {byCategory.length > 0 && ( - - Top Categories + + Categorías principales
- {byCategory.slice(0, 8).map((cat, i) => ( + {folded.map((cat, i) => (
{cat.category_name} {cat.count} txns @@ -332,8 +369,8 @@ export default function Analytics() {
@@ -343,28 +380,25 @@ export default function Analytics() { )} - {/* Exchange rate history */} + + {/* Tipo de cambio */} - - Tipo de Cambio USD/CRC (90 días) + + Tipo de cambio USD/CRC (90 días) {(ratesQ.data?.length ?? 0) < 2 ? ( -
- Sin historial suficiente -
+ ) : ( - + new Date(v).toLocaleDateString('es-CR', { month: 'short', @@ -385,19 +419,22 @@ export default function Analytics() { /> } /> + } />