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

@@ -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<ChartConfig>((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) => (
<Cell key={cat.category_name} fill={sliceColor(i, cat.category_name)} />
<Cell key={cat.category_name} fill={categoricalColor(i, cat.category_name)} />
))}
</Pie>
<ChartTooltip
@@ -244,7 +231,7 @@ export default function Analytics() {
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
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 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
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 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"
style={{
width: `${Math.min(100, cat.percentage)}%`,
background: sliceColor(i, cat.category_name),
background: categoricalColor(i, cat.category_name),
}}
/>
</div>