Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
CumulativeSpendChart,
} from "../components/widgets/CumulativeSpendChart";
import {
tooltipFormatter,
tooltipLabelFormatter,
} from "../components/widgets/cumulative-spend-chart-utils";

// ResponsiveContainer won't render children without real DOM dimensions.
// Mock it to render children directly so chart internals execute.
vi.mock("recharts", async (importOriginal) => {
const actual = await importOriginal<typeof import("recharts")>();
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div style={{ width: 800, height: 300 }}>{children}</div>
),
};
});

// Data that produces a crossover: actual starts above ideal, then drops below
const testData = [
{ day: 1, actual: 20000, ideal: 10000 },
{ day: 2, actual: 35000, ideal: 20000 },
{ day: 3, actual: 40000, ideal: 30000 },
{ day: 4, actual: 42000, ideal: 40000 },
{ day: 5, actual: 44000, ideal: 50000 },
{ day: 6, actual: 46000, ideal: 60000 },
{ day: 7, actual: 48000, ideal: 70000 },
];

describe("CumulativeSpendChart", () => {
it("renders chart card with title", () => {
render(<CumulativeSpendChart data={testData} currency="GBP" />);
expect(screen.getByText("Cumulative Spending")).toBeInTheDocument();
});

it("renders with empty data without crashing", () => {
render(<CumulativeSpendChart data={[]} currency="USD" />);
expect(screen.getByText("Cumulative Spending")).toBeInTheDocument();
});
});

describe("tooltipFormatter", () => {
it("returns null for array values (range area tuples)", () => {
expect(tooltipFormatter([100, 200], "surplus", "GBP")).toBeNull();
});

it("formats scalar values as currency", () => {
const result = tooltipFormatter(150, "Actual", "GBP");
expect(result).not.toBeNull();
expect(result![0]).toContain("£");
expect(result![1]).toBe("Actual");
});
});

describe("tooltipLabelFormatter", () => {
it("formats integer day labels", () => {
expect(tooltipLabelFormatter(5)).toBe("Day 5");
expect(tooltipLabelFormatter(17)).toBe("Day 17");
});

it("returns empty string for fractional crossover values", () => {
expect(tooltipLabelFormatter(17.16)).toBe("");
expect(tooltipLabelFormatter(4.5)).toBe("");
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { formatCurrency, getCurrencySymbol } from "@gofin/core";
import { getCurrencySymbol } from "@gofin/core";
import type { CumulativeSpendPoint } from "../../../../types";
import { insertCrossoverPoints } from "../../../../lib/insertCrossoverPoints";
import {
tooltipFormatter,
tooltipLabelFormatter,
} from "./cumulative-spend-chart-utils";
import {
Card,
CardContent,
Expand Down Expand Up @@ -40,22 +44,26 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro
const underBudget = point.actual <= point.ideal;

if (point.isCrossover) {
// At crossover, define both areas to terminate/start seamlessly
// At crossover, both areas collapse to zero height for seamless transition
return {
day: point.day,
actual: point.actual,
ideal: point.ideal,
surplusTop: point.actual,
deficitTop: point.actual,
surplus: [point.actual, point.actual] as [number, number],
deficit: [point.actual, point.actual] as [number, number],
};
}

return {
day: point.day,
actual: point.actual,
ideal: point.ideal,
surplusTop: underBudget ? point.ideal : undefined,
deficitTop: !underBudget ? point.actual : undefined,
surplus: underBudget
? ([point.actual, point.ideal] as [number, number])
: undefined,
deficit: !underBudget
? ([point.ideal, point.actual] as [number, number])
: undefined,
};
});

Expand All @@ -77,17 +85,22 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="day"
type="number"
domain={[1, basePoints.length]}
ticks={basePoints.map((p) => p.day)}
allowDecimals={false}
label={{ value: "Day of Month", position: "insideBottom", offset: -5 }}
/>
<YAxis
tickFormatter={(value) => `${getCurrencySymbol(currency)}${value}`}
/>
<Tooltip
formatter={(value) => formatCurrency((value as number) * 100, currency)}
formatter={(value, name) => tooltipFormatter(value, name as string, currency)}
labelFormatter={(label) => tooltipLabelFormatter(label)}
/>
<Area
type="monotone"
dataKey="surplusTop"
dataKey="surplus"
fill="rgba(34, 197, 94, 0.50)"
stroke="none"
name="Under Budget"
Expand All @@ -96,7 +109,7 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro
/>
<Area
type="monotone"
dataKey="deficitTop"
dataKey="deficit"
fill="rgba(239, 68, 68, 0.50)"
stroke="none"
name="Over Budget"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { formatCurrency } from "@gofin/core";

/**
* Tooltip value formatter for the cumulative spend chart.
* Returns null for array values (range area tuples) to suppress them in tooltip.
*/
export function tooltipFormatter(
value: unknown,
name: string,
currency: string,
): [string, string] | null {
if (Array.isArray(value)) return null;
return [formatCurrency((value as number) * 100, currency), name];
}

/**
* Tooltip label formatter that shows "Day N" for integer days
* and suppresses display for fractional crossover points.
*/
export function tooltipLabelFormatter(label: unknown): string {
return Number.isInteger(label) ? `Day ${label}` : "";
}
Loading