import React, { useMemo, 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,
FALLBACK_TICK,
FALLBACK_PRIMARY,
FALLBACK_SECONDARY,
formatTooltipDate,
ChartPeriodButton,
} from "./chart-shared";
ChartJS.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
Tooltip,
Legend,
);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CashflowDataPoint {
/** ISO date string e.g. "2024-01-01" */
date: string;
income: number;
expenses: number;
/** Positive value — ignored when overspending > 0 */
surplus: number;
/** Positive value — ignored when surplus > 0 */
overspending: number;
}
export interface CashflowChartData {
months: string[];
data: CashflowDataPoint[];
}
export type CashflowPeriod = 3 | 6 | 12;
export interface CashflowBarChartProps {
/** Full dataset — sliced to the selected period */
cashflowData: CashflowChartData | 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?: CashflowPeriod;
/** Show or hide the period selector buttons (3M / 6M / 12M) */
showPeriodSelector?: boolean;
/** 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;
}
// ---------------------------------------------------------------------------
// HTML legend (outside canvas — full spacing control)
// ---------------------------------------------------------------------------
interface LegendItemProps {
label: string;
fillColor: string;
strokeColor: string;
strokeWidth?: number;
}
function LegendItem({
label,
fillColor,
strokeColor,
strokeWidth = 1.5,
}: LegendItemProps) {
return (
0 ? `${strokeWidth}px solid ${strokeColor}` : "none",
flexShrink: 0,
}}
/>
{label}
);
}
function ChartLegend({
primary,
secondary,
}: {
primary: string;
secondary: string;
}) {
return (
);
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PERIODS: CashflowPeriod[] = [3, 6, 12];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CashflowBarChart({
cashflowData,
title = "Cashflow",
showLegend = true,
showXAxis = true,
showYAxis = true,
legendPosition = "top",
defaultPeriod = 6,
showPeriodSelector = true,
height = 280,
width = "100%",
className,
isLoading = false,
}: CashflowBarChartProps) {
const [period, setPeriod] = useState
(defaultPeriod);
// Read theme colors from ThemeProvider context.
// Falls back to WealthX defaults when no ThemeProvider is present.
const themeVars = useThemeVars();
const brandPrimary: string =
(themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY;
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 (!cashflowData?.data?.length) return null;
const count = Math.min(period, cashflowData.data.length);
const start = cashflowData.data.length - count;
return {
months: cashflowData.months.slice(start),
data: cashflowData.data.slice(start),
};
}, [cashflowData, period]);
const chartData = useMemo>(() => {
if (!sliced) return { labels: [], datasets: [] };
return {
labels: sliced.months,
datasets: [
{
label: "Income",
data: sliced.data.map((d) => d.income),
backgroundColor: hexToRgba(brandPrimary, 0.2),
hoverBackgroundColor: hexToRgba(brandPrimary, 0.35),
borderColor: brandPrimary,
borderWidth: 1.5,
borderRadius: 0,
borderSkipped: false,
barPercentage: 0.75,
categoryPercentage: 0.7,
},
{
label: "Expenses and Liabilities",
data: sliced.data.map((d) => d.expenses),
backgroundColor: hexToRgba(brandSecondary, 0.2),
hoverBackgroundColor: hexToRgba(brandSecondary, 0.35),
borderColor: brandSecondary,
borderWidth: 1.5,
borderRadius: 0,
borderSkipped: false,
barPercentage: 0.75,
categoryPercentage: 0.7,
},
{
label: "_thirdBar",
data: sliced.data.map((d) =>
d.overspending > 0 ? d.overspending : d.surplus,
),
backgroundColor: sliced.data.map((d) =>
d.overspending > 0 ? brandSecondary : brandPrimary,
),
hoverBackgroundColor: sliced.data.map((d) =>
d.overspending > 0
? hexToRgba(brandSecondary, 0.8)
: hexToRgba(brandPrimary, 0.8),
),
borderWidth: 0,
borderRadius: 0,
borderSkipped: false,
barPercentage: 0.75,
categoryPercentage: 0.7,
},
],
};
}, [sliced, brandPrimary, 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: 11, weight: 600 },
bodyFont: { size: 12, weight: 500 },
callbacks: {
title: (tooltipItems) => {
const idx = tooltipItems[0]?.dataIndex;
if (idx != null && sliced?.data[idx]?.date) {
return formatTooltipDate(sliced.data[idx].date, "monthly");
}
return tooltipItems[0]?.label ?? "";
},
label: (ctx) => {
const val = ctx.raw as number;
if (val === 0) return;
if (ctx.datasetIndex === 2) {
const d = sliced?.data[ctx.dataIndex];
if (!d) return;
const lbl =
d.overspending > 0 ? "Over Spending" : "Surplus Income";
return ` ${lbl}: $${val.toLocaleString()}`;
}
return ` ${ctx.dataset.label}: $${val.toLocaleString()}`;
},
},
},
},
scales: {
x: {
display: showXAxis,
grid: { display: false },
border: { display: false },
ticks: {
font: { size: 12, family: fontFamily },
color: FALLBACK_TICK,
},
},
y: {
display: showYAxis,
grid: { display: false },
border: { display: false },
ticks: {
font: { size: 12, family: fontFamily },
color: FALLBACK_TICK,
maxTicksLimit: 5,
padding: 8,
callback: (v) => `$${Number(v).toLocaleString()}`,
},
},
},
}),
[showXAxis, showYAxis, sliced, fontFamily],
);
return (
{title}
{showPeriodSelector && (
{PERIODS.map((p) => (
setPeriod(p)}
/>
))}
)}
{isLoading ? (
) : !sliced ? (
No data available
) : (
{showLegend && legendPosition === "top" && (
)}
{showLegend && legendPosition === "bottom" && (
)}
)}
);
}