mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
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:
@@ -1,12 +1,11 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { PieChart, Pie, Cell } from 'recharts';
|
import { PieChart, Pie, Cell } from 'recharts';
|
||||||
|
|
||||||
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -23,29 +22,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from 'lucide-react';
|
} 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 }> = {
|
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
|
||||||
CASH: { label: 'Efectivo', icon: Banknote },
|
CASH: { label: 'Efectivo', icon: Banknote },
|
||||||
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
|
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) {
|
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
|
||||||
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
|
|
||||||
|
|
||||||
if (loading || !detail) {
|
if (loading || !detail) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
|
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
|
||||||
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
|
<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 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 }));
|
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
|
||||||
|
|
||||||
// For ChatGPT mode: assign colors by rank (largest = darkest)
|
// Sequential ramps: one hue per concept, largest slice = full token,
|
||||||
// For Gemini mode: assign colors by position (qualitative)
|
// smaller slices fade toward the surface (rank-ordered).
|
||||||
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
|
function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
|
||||||
if (paletteMode === 'chatgpt') {
|
const sorted = [...data].sort((a, b) => b.value - a.value);
|
||||||
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
|
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
data.forEach((item, i) => {
|
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
|
||||||
map.set(item.name, colors[i % colors.length]);
|
|
||||||
});
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomeColorMap = buildColorMap(incomeData, incomeColors);
|
const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
|
||||||
const expenseColorMap = buildColorMap(expenseData, expenseColors);
|
const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
|
||||||
|
|
||||||
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
|
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
|
||||||
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
|
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
|
||||||
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// CC spending by category
|
// CC by category: identity → categorical slots; past the 5th fold into "Otros".
|
||||||
const ccData = (detail.cc_by_category ?? []).map((item) => ({
|
const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
|
||||||
name: item.category_name,
|
name: item.category_name,
|
||||||
value: item.amount,
|
value: item.amount,
|
||||||
}));
|
}));
|
||||||
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
|
const ccData =
|
||||||
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
|
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;
|
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)
|
// Filter actuals to only cash and transfer (no credit card)
|
||||||
const cashTransferActuals = detail.actuals_by_source.filter(
|
const cashTransferActuals = detail.actuals_by_source.filter(
|
||||||
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* Pie Charts */}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Income Pie */}
|
{/* Income Pie */}
|
||||||
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{incomeData.map((item, i) => (
|
{incomeData.map((item, i) => (
|
||||||
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
|
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
|
||||||
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{expenseData.map((item, i) => (
|
{expenseData.map((item, i) => (
|
||||||
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
|
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
|
||||||
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{ccData.map((_, i) => (
|
{ccData.map((item) => (
|
||||||
<Cell key={i} fill={ccColors[i % ccColors.length]} />
|
<Cell key={item.name} fill={ccColorOf(item.name)} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
|
<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 key={item.name} className="flex items-center gap-1.5 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full shrink-0"
|
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>
|
<span className="truncate text-muted-foreground">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
frontend/src/lib/charts.ts
Normal file
32
frontend/src/lib/charts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** Shared chart color system (see dataviz standard).
|
||||||
|
*
|
||||||
|
* - Categorical (identity): the five validated --chart-N slots, assigned in
|
||||||
|
* fixed order, NEVER cycled — anything past the 5th folds into "Otros".
|
||||||
|
* - Sequential (magnitude within one concept, e.g. income sources ranked by
|
||||||
|
* size): one hue ramped toward the surface via color-mix, which stays
|
||||||
|
* correct in both light and dark mode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SERIES_VARS = [
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
"var(--chart-3)",
|
||||||
|
"var(--chart-4)",
|
||||||
|
"var(--chart-5)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OTROS_COLOR = "var(--muted-foreground)";
|
||||||
|
export const MAX_SLICES = SERIES_VARS.length;
|
||||||
|
|
||||||
|
export function categoricalColor(index: number, name?: string): string {
|
||||||
|
if (name === "Otros" || name === "Sin categoría") return OTROS_COLOR;
|
||||||
|
return SERIES_VARS[Math.min(index, SERIES_VARS.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rank-ordered shade of one hue: rank 0 (largest) is the full token, later
|
||||||
|
* ranks fade toward the surface but never below 35% so they keep chroma. */
|
||||||
|
export function rampColor(baseVar: string, rank: number, count: number): string {
|
||||||
|
if (count <= 1 || rank <= 0) return baseVar;
|
||||||
|
const pct = Math.round(100 - (rank / Math.max(count - 1, 1)) * 65);
|
||||||
|
return `color-mix(in oklab, ${baseVar} ${pct}%, var(--background))`;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { BarChart3, ChartPie } from 'lucide-react';
|
import { BarChart3, ChartPie } from 'lucide-react';
|
||||||
|
|
||||||
import api, { getRateHistory } from '@/lib/api';
|
import api, { getRateHistory } from '@/lib/api';
|
||||||
|
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
|
||||||
import ErrorState from '@/components/ErrorState';
|
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';
|
||||||
@@ -57,17 +58,6 @@ interface DailySpending {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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) {
|
function formatCRC(value: number) {
|
||||||
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
||||||
@@ -90,9 +80,6 @@ function foldCategories(cats: CategorySpending[]) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sliceColor(index: number, name: string) {
|
|
||||||
return name === 'Otros' ? OTROS_COLOR : SERIES_VARS[index % SERIES_VARS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartEmpty({ description }: { description: string }) {
|
function ChartEmpty({ description }: { description: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +162,7 @@ export default function Analytics() {
|
|||||||
const pieChartConfig = folded.reduce<ChartConfig>((acc, cat, i) => {
|
const pieChartConfig = folded.reduce<ChartConfig>((acc, cat, i) => {
|
||||||
acc[cat.category_name] = {
|
acc[cat.category_name] = {
|
||||||
label: cat.category_name,
|
label: cat.category_name,
|
||||||
color: sliceColor(i, cat.category_name),
|
color: categoricalColor(i, cat.category_name),
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -225,7 +212,7 @@ export default function Analytics() {
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{folded.map((cat, i) => (
|
{folded.map((cat, i) => (
|
||||||
<Cell key={cat.category_name} fill={sliceColor(i, cat.category_name)} />
|
<Cell key={cat.category_name} fill={categoricalColor(i, cat.category_name)} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -244,7 +231,7 @@ export default function Analytics() {
|
|||||||
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ background: sliceColor(i, cat.category_name) }}
|
style={{ background: categoricalColor(i, cat.category_name) }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
||||||
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
||||||
@@ -358,7 +345,7 @@ export default function Analytics() {
|
|||||||
<div key={cat.category_name} className="flex items-center gap-3">
|
<div key={cat.category_name} className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
style={{ background: sliceColor(i, cat.category_name) }}
|
style={{ background: categoricalColor(i, cat.category_name) }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||||
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||||
@@ -370,7 +357,7 @@ export default function Analytics() {
|
|||||||
className="h-1.5 rounded-full"
|
className="h-1.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(100, cat.percentage)}%`,
|
width: `${Math.min(100, cat.percentage)}%`,
|
||||||
background: sliceColor(i, cat.category_name),
|
background: categoricalColor(i, cat.category_name),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user