Budget donuts: sequential ramps + categorical slots, Paleta toggle retired

lib/charts.ts centralizes the color system: fixed categorical slots
(never cycled, >5 folds into Otros, 'Sin categoría' reads neutral) and
color-mix ramps that stay correct in dark mode. Animations off so
charts render deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 12:47:20 -06:00
parent 2cf19f1880
commit 9907ff265e
3 changed files with 70 additions and 100 deletions

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
ChartContainer,
@@ -23,29 +22,6 @@ import {
Info,
} from 'lucide-react';
type PaletteMode = 'chatgpt' | 'gemini';
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
@@ -91,16 +67,9 @@ function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof Tr
}
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) {
return (
<div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
@@ -172,32 +141,20 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
);
}
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
// Sequential ramps: one hue per concept, largest slice = full token,
// smaller slices fade toward the surface (rank-ordered).
function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
// CC by category: identity → categorical slots; past the 5th fold into "Otros".
const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
const ccData =
ccRaw.length <= MAX_SLICES
? ccRaw
: [
...ccRaw.slice(0, MAX_SLICES),
{
name: 'Otros',
value: ccRaw.slice(MAX_SLICES).reduce((s, c) => s + c.value, 0),
},
];
const ccColorOf = (name: string) =>
categoricalColor(ccData.findIndex((c) => c.name === name), name);
const ccConfig = ccData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: ccColorOf(item.name) };
return acc;
}, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
const ccTotal = ccRaw.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter(
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return (
<div className="space-y-4">
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
{/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
isAnimationActive={false}
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
{ccData.map((item) => (
<Cell key={item.name} fill={ccColorOf(item.name)} />
))}
</Pie>
<ChartTooltip
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => (
{ccData.map((item) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }}
style={{ background: ccColorOf(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>