import React, { useMemo } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_BG, ChartLegendItem, formatAbbrev, formatChartDateLabel, formatTooltipDate, } from "./chart-shared"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Spinner } from "./spinner"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; ChartJS.register( CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, ); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Format ISO date string to "Jan 15" label — delegates to chart-shared. */ function formatDateLabel(iso: string): string { return formatChartDateLabel(iso); } /** Format ISO date string for tooltip "Jan 15, 2024" — delegates to chart-shared. */ function formatDateTooltip(iso: string): string { return formatTooltipDate(iso, "daily"); } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CashBalanceDataPoint { /** ISO date string e.g. "2024-01-15" */ x: string; y: number; } /** * Optional secondary line datasets rendered on a right-hand Y-axis. * Useful for overlaying income / expense lines against the balance. */ export interface ExtraLineDataset { label: string; data: CashBalanceDataPoint[]; color: string; dashed?: boolean; } export interface CashBalanceLineChartProps { chartData?: CashBalanceDataPoint[] | null; title?: string; /** Chart canvas height in pixels */ height?: number; /** Width of the card */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; /** Show or hide X axis labels */ showXAxis?: boolean; /** Show or hide Y axis labels */ showYAxis?: boolean; /** Show the latest balance value prominently below the title */ showBalanceValue?: boolean; /** Show or hide the chart legend */ showLegend?: boolean; /** Legend placement relative to the chart */ legendPosition?: "top" | "bottom"; /** * Optional secondary line datasets (e.g. income / expense) rendered on a * right-hand Y-axis so their scale stays independent from the balance line. */ extraDatasets?: ExtraLineDataset[]; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function CashBalanceLineChart({ chartData, title = "Cash Balance", height = 200, width = "100%", className, isLoading = false, showXAxis = true, showYAxis = true, showBalanceValue = false, showLegend = false, legendPosition = "bottom", extraDatasets, }: CashBalanceLineChartProps) { const themeVars = useThemeVars(); const brandPrimary = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const fontFamily = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const hasData = Array.isArray(chartData) && chartData.length > 0; // Indices to show on x-axis (max ~6 ticks) const tickIndices = useMemo>(() => { if (!hasData) return new Set(); const count = chartData!.length; const step = Math.max(1, Math.floor(count / 6)); const indices = new Set(); for (let i = 0; i < count; i += step) indices.add(i); indices.add(count - 1); return indices; }, [hasData, chartData]); const hasExtra = Array.isArray(extraDatasets) && extraDatasets.length > 0; const data = useMemo>(() => { if (!hasData) return { labels: [], datasets: [] }; const mainDs = { label: title, data: chartData!.map((p) => p.y), fill: false, borderColor: brandPrimary, backgroundColor: "transparent", borderWidth: 2.5, borderDash: [], tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: brandPrimary, pointHoverBorderWidth: 3, pointHitRadius: 10, yAxisID: "y", }; const extraDs = hasExtra ? extraDatasets!.map((ds) => ({ label: ds.label, data: ds.data.map((p) => p.y), fill: false, borderColor: ds.color, backgroundColor: "transparent", borderWidth: 1.5, borderDash: ds.dashed ? ([5, 4] as number[]) : ([] as number[]), tension: 0.4, pointRadius: 0, pointHoverRadius: 4, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: ds.color, pointHoverBorderWidth: 2, pointHitRadius: 10, yAxisID: "y2", })) : []; return { labels: chartData!.map((p) => p.x), datasets: [mainDs, ...extraDs], }; }, [hasData, chartData, brandPrimary, title, hasExtra, extraDatasets]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 1200, easing: "easeOutQuart" }, plugins: { legend: { display: false }, tooltip: { enabled: true, mode: "index", intersect: false, displayColors: hasExtra, padding: 12, cornerRadius: 0, titleFont: { size: 11, weight: 600, family: fontFamily }, bodyFont: { size: 12, weight: 500, family: fontFamily }, callbacks: { title: (items) => { const iso = items[0]?.label; return iso ? formatDateTooltip(iso) : ""; }, label: (ctx) => { const value = formatCurrency((ctx.parsed.y ?? 0), { decimals: 2 }); return hasExtra ? ` ${ctx.dataset.label}: ${value}` : value; }, }, }, }, interaction: { mode: "index", intersect: false }, scales: { x: { type: "category", display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { autoSkip: false, maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: function (_, index) { if (!tickIndices.has(index) || !chartData) return ""; return formatDateLabel(chartData[index].x); }, }, }, y: { display: showYAxis, position: "left", grid: { display: false }, border: { display: false }, ticks: { beginAtZero: false, maxTicksLimit: 5, padding: 8, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: (v) => formatAbbrev(Number(v)), }, }, y2: { display: hasExtra && showYAxis, position: "right" as const, beginAtZero: true, grid: { display: false }, border: { display: false }, ticks: { maxTicksLimit: 5, padding: 8, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: (v) => formatAbbrev(Number(v)), }, }, }, }), [tickIndices, chartData, showXAxis, showYAxis, fontFamily, hasExtra], ); const latestValue = hasData ? chartData![chartData!.length - 1].y : null; return ( {(title || (showBalanceValue && latestValue !== null)) && (
{title && ( {title} )} {showBalanceValue && latestValue !== null && (

{formatCurrency(latestValue, { decimals: 2 })}

)}
)} {isLoading ? (
) : !hasData ? ( No data available ) : (
{showLegend && legendPosition === "top" && (
{title && ( )} {extraDatasets?.map((ds) => ( ))}
)}
{showLegend && legendPosition === "bottom" && (
{title && ( )} {extraDatasets?.map((ds) => ( ))}
)}
)}
); }