From 9907ff265eec5634ef1f4fa2db4b4ae43dc5055b Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 12:47:20 -0600 Subject: [PATCH] Budget donuts: sequential ramps + categorical slots, Paleta toggle retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/budget/MonthlyDetail.tsx | 113 +++++------------- frontend/src/lib/charts.ts | 32 +++++ frontend/src/pages/Analytics.tsx | 25 +--- 3 files changed, 70 insertions(+), 100 deletions(-) create mode 100644 frontend/src/lib/charts.ts diff --git a/frontend/src/components/budget/MonthlyDetail.tsx b/frontend/src/components/budget/MonthlyDetail.tsx index 6fb9c52..1a6c657 100644 --- a/frontend/src/components/budget/MonthlyDetail.tsx +++ b/frontend/src/components/budget/MonthlyDetail.tsx @@ -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 = { - 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 = { 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('chatgpt'); - if (loading || !detail) { return (
-
- Paleta: - - -
@@ -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 { - if (paletteMode === 'chatgpt') { - const sorted = [...data].sort((a, b) => b.value - a.value); - const map = new Map(); - 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 { + const sorted = [...data].sort((a, b) => b.value - a.value); const map = new Map(); - 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((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((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((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 (
- {/* Palette Toggle */} -
- Paleta: - - -
- {/* Pie Charts */}
{/* 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) => ( @@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction paddingAngle={2} strokeWidth={2} stroke="var(--card)" + isAnimationActive={false} > {expenseData.map((item, i) => ( @@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction paddingAngle={2} strokeWidth={2} stroke="var(--card)" + isAnimationActive={false} > - {ccData.map((_, i) => ( - + {ccData.map((item) => ( + ))}
- {ccData.map((item, i) => ( + {ccData.map((item) => (
{item.name}
diff --git a/frontend/src/lib/charts.ts b/frontend/src/lib/charts.ts new file mode 100644 index 0000000..326ab71 --- /dev/null +++ b/frontend/src/lib/charts.ts @@ -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))`; +} diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index de9e6cd..f258f6a 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -14,6 +14,7 @@ import { import { BarChart3, ChartPie } from 'lucide-react'; import api, { getRateHistory } from '@/lib/api'; +import { categoricalColor, MAX_SLICES } from '@/lib/charts'; import ErrorState from '@/components/ErrorState'; import BillingCycleSelector from '@/components/BillingCycleSelector'; import { PageHeader } from '@/components/page-header'; @@ -57,17 +58,6 @@ interface DailySpending { 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) { 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 }) { return ( @@ -175,7 +162,7 @@ export default function Analytics() { const pieChartConfig = folded.reduce((acc, cat, i) => { acc[cat.category_name] = { label: cat.category_name, - color: sliceColor(i, cat.category_name), + color: categoricalColor(i, cat.category_name), }; return acc; }, {}); @@ -225,7 +212,7 @@ export default function Analytics() { isAnimationActive={false} > {folded.map((cat, i) => ( - + ))}
{cat.category_name} {cat.percentage}% @@ -358,7 +345,7 @@ export default function Analytics() {
{cat.category_name} {cat.count} txns @@ -370,7 +357,7 @@ export default function Analytics() { className="h-1.5 rounded-full" style={{ width: `${Math.min(100, cat.percentage)}%`, - background: sliceColor(i, cat.category_name), + background: categoricalColor(i, cat.category_name), }} />