import React, { useEffect, useMemo, useRef, useState } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { Card, CardContent, CardHeader, CardTitle, CardAction } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Skeleton } from "./skeleton"; import { cn } from "@/lib/utils"; import { hexToRgba, DATASET_ALPHAS, FALLBACK_TICK, FALLBACK_SECONDARY, CHART_SLICE_COUNT, CHART_PERIODS, formatTooltipDate, ChartLegendItem, ChartPeriodButton, type ChartPeriod, type ChartGranularity, } from "./chart-shared"; ChartJS.register( CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ExpenseDataset { /** Expense category label, e.g. "Housing", "Food", "Transport" */ label: string; /** One value per data point, aligned to the months array */ data: number[]; } export interface ExpenseBarChartData { /** Display labels, e.g. ["Jul", "Aug"] for monthly or ["Mar 8", "Mar 9"] for daily */ months: string[]; /** Optional ISO date strings per point — used for the tooltip title */ dates?: string[]; datasets: ExpenseDataset[]; } export type ExpensePeriod = ChartPeriod; export type ExpenseGranularity = ChartGranularity; export interface ExpenseBarChartProps { /** Full dataset — sliced to the selected period */ expenseData: ExpenseBarChartData | null; title?: string; /** Show or hide the chart legend */ showLegend?: boolean; /** Show or hide X axis labels */ showXAxis?: boolean; /** Show or hide Y axis labels */ showYAxis?: boolean; /** Legend placement relative to chart */ legendPosition?: "top" | "bottom"; /** Default period selector value */ defaultPeriod?: ExpensePeriod; /** * Data granularity — controls available period buttons and slice counts. * "monthly" (default): shows 3M / 6M / 12M, slices by month count. * "daily": shows 1M / 3M / 6M / 12M, slices by day count (1M=30, 3M=90, etc.). */ granularity?: ExpenseGranularity; /** Chart canvas height in pixels */ height?: number; /** Width of the card in pixels */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; /** * Show the internal period selector buttons (3M / 6M / 12M). * Set to `false` when the chart is driven by an external period control. * Defaults to `true`. */ showPeriodSelector?: boolean; } // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function ExpenseBarChart({ expenseData, title = "Expenses", showLegend = true, showXAxis = true, showYAxis = true, legendPosition = "top", defaultPeriod = 6, granularity = "monthly", height = 280, width = "100%", className, isLoading = false, showPeriodSelector = true, }: ExpenseBarChartProps) { const periods = CHART_PERIODS[granularity]; const [period, setPeriod] = useState(defaultPeriod); // Reset period when granularity changes, but not on initial mount // (defaultPeriod handles the initial value). const isFirstRender = useRef(true); useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } setPeriod(CHART_PERIODS[granularity][0]); }, [granularity]); const themeVars = useThemeVars(); const brandSecondary: string = (themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY; const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const sliced = useMemo(() => { if (!expenseData?.months?.length || !expenseData.datasets.length) return null; const count = Math.min( CHART_SLICE_COUNT[granularity][period], expenseData.months.length, ); const start = expenseData.months.length - count; return { months: expenseData.months.slice(start), dates: expenseData.dates?.slice(start), datasets: expenseData.datasets.map((ds) => ({ ...ds, data: ds.data.slice(start), })), }; }, [expenseData, period, granularity]); const datasetColors = useMemo( () => sliced?.datasets.map((_, i) => hexToRgba(brandSecondary, DATASET_ALPHAS[i % DATASET_ALPHAS.length]), ) ?? [], [sliced, brandSecondary], ); const chartData = useMemo>(() => { if (!sliced) return { labels: [], datasets: [] }; return { labels: sliced.months, datasets: sliced.datasets.map((ds, i) => ({ label: ds.label, data: ds.data, backgroundColor: datasetColors[i], hoverBackgroundColor: hexToRgba( brandSecondary, Math.min(DATASET_ALPHAS[i % DATASET_ALPHAS.length] + 0.15, 1), ), borderWidth: 0, borderRadius: 0, borderSkipped: false, barPercentage: 0.75, categoryPercentage: 0.7, stack: "expense", })), }; }, [sliced, datasetColors, brandSecondary]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, layout: { padding: 0 }, plugins: { legend: { display: false }, tooltip: { mode: "index", intersect: false, padding: 12, cornerRadius: 0, titleFont: { size: 12, weight: 600 }, bodyFont: { size: 12, weight: 500 }, callbacks: { title: (tooltipItems) => { const idx = tooltipItems[0]?.dataIndex; if (idx != null && sliced?.dates?.[idx]) { return formatTooltipDate(sliced.dates[idx], granularity); } return tooltipItems[0]?.label ?? ""; }, label: (ctx) => { const val = ctx.raw as number; if (val === 0) return; return ` ${ctx.dataset.label}: $${val.toLocaleString()}`; }, }, }, }, scales: { x: { display: showXAxis, stacked: true, grid: { display: false }, border: { display: false }, ticks: { font: { size: 12 }, color: FALLBACK_TICK }, }, y: { display: showYAxis, stacked: true, grid: { display: false }, border: { display: false }, ticks: { font: { size: 12 }, color: FALLBACK_TICK, maxTicksLimit: 5, padding: 8, callback: (v) => `$${Number(v).toLocaleString()}`, }, }, }, }), [showXAxis, showYAxis, sliced, granularity], ); return ( {(title || showPeriodSelector) && ( {title && {title}} {showPeriodSelector && (
{periods.map((p) => ( setPeriod(p)} /> ))}
)}
)} {isLoading ? ( ) : !sliced ? ( No data available ) : (
{showLegend && legendPosition === "top" && (
{sliced.datasets.map((ds, i) => ( ))}
)}
{showLegend && legendPosition === "bottom" && (
{sliced.datasets.map((ds, i) => ( ))}
)}
)}
); }