/** * Configurable chart panel. Renders one ChartConfig as a Recharts chart, * with inline controls for type, metrics, granularity, and axis ranges. */ import { useMemo, useState } from 'react'; import { ResponsiveContainer, LineChart, Line, BarChart, Bar, AreaChart, Area, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, Legend, CartesianGrid, } from 'recharts'; import type { ChartConfig, ChartMetric, ChartType, ChartGranularity } from '@/types'; import { buildChartSeries } from '@/lib/stats/aggregate'; import { useAppStore } from '@/store/appStore'; import { fmtMoneyShort } from '@/lib/format'; const METRIC_LABELS: Record = { workValue: 'Work Value', payments: 'Payments', expenses: 'Expenses', netIncome: 'Net Income', cumulativePayments: 'Cumulative Payments', cumulativeNet: 'Cumulative Net', }; const CHART_COLORS = [ 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', ]; interface Props { config: ChartConfig; onChange: (patch: Partial) => void; onRemove?: () => void; } export function ChartPanel({ config, onChange, onRemove }: Props) { const work = useAppStore((s) => s.data.workEntries); const payments = useAppStore((s) => s.data.payments); const expenses = useAppStore((s) => s.data.expenses); const [showControls, setShowControls] = useState(false); const data = useMemo( () => buildChartSeries( work, payments, expenses, config.metrics, config.granularity, config.rangeStart, config.rangeEnd, ), [work, payments, expenses, config], ); const yDomain: [number | 'auto', number | 'auto'] = [ config.yMin ?? 'auto', config.yMax ?? 'auto', ]; const toggleMetric = (m: ChartMetric) => { const has = config.metrics.includes(m); const next = has ? config.metrics.filter((x) => x !== m) : [...config.metrics, m]; if (next.length > 0) onChange({ metrics: next }); }; return (
onChange({ title: e.target.value })} />
{onRemove && ( )}
{showControls && (
{/* Chart type */}
Type
{(['line', 'bar', 'area', 'pie'] as ChartType[]).map((t) => ( ))}
{/* Granularity */}
Grain
{(['day', 'week', 'month', 'year'] as ChartGranularity[]).map((g) => ( ))}
{/* Metrics */}
Data {(Object.keys(METRIC_LABELS) as ChartMetric[]).map((m) => ( ))}
{/* X range */}
X range onChange({ rangeStart: e.target.value || null })} /> to onChange({ rangeEnd: e.target.value || null })} />
{/* Y range */}
Y range onChange({ yMin: e.target.value ? Number(e.target.value) : null })} /> to onChange({ yMax: e.target.value ? Number(e.target.value) : null })} />
)} {/* Chart render */}
{data.length === 0 ? (
No data to display
) : ( {renderChart(config, data, yDomain)} )}
); } function renderChart( config: ChartConfig, data: ReturnType, yDomain: [number | 'auto', number | 'auto'], ) { const common = { data, margin: { top: 5, right: 10, bottom: 5, left: 0 }, }; const axes = ( <> fmtMoneyShort(v)} /> fmtMoneyShort(v)} /> ); switch (config.type) { case 'line': return ( {axes} {config.metrics.map((m, i) => ( ))} ); case 'bar': return ( {axes} {config.metrics.map((m, i) => ( ))} ); case 'area': return ( {axes} {config.metrics.map((m, i) => ( ))} ); case 'pie': { // Pie: sum each metric over the range const totals = config.metrics.map((m) => ({ name: METRIC_LABELS[m], value: data.reduce((s, d) => s + (Number(d[m]) || 0), 0), })); return ( {totals.map((_, i) => ( ))} fmtMoneyShort(v)} /> ); } } }