From 43418ed3ebda561b288ef7ae4236e34261b3b408 Mon Sep 17 00:00:00 2001 From: thompson Date: Wed, 10 Jun 2026 19:36:15 +0100 Subject: [PATCH 1/2] fix(chart): use range tuples for area shading between lines - Change surplus/deficit data from single values to [lower, upper] tuples so Recharts renders fill only between actual and ideal lines, not to y=0 - Convert XAxis to type=number with explicit integer ticks to eliminate phantom tick slots from fractional crossover day values - Add custom Tooltip content that filters out non-integer day points and excludes array-valued (range area) entries from display --- .../widgets/CumulativeSpendChart.tsx | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx index e10b8ba..65fecf2 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx @@ -40,13 +40,13 @@ 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], }; } @@ -54,8 +54,12 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro 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, }; }); @@ -77,17 +81,28 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro p.day)} + allowDecimals={false} label={{ value: "Day of Month", position: "insideBottom", offset: -5 }} /> `${getCurrencySymbol(currency)}${value}`} /> formatCurrency((value as number) * 100, currency)} + formatter={(value, name) => + Array.isArray(value) + ? null + : [formatCurrency((value as number) * 100, currency), name] + } + labelFormatter={(label) => + Number.isInteger(label) ? `Day ${label}` : "" + } /> Date: Wed, 10 Jun 2026 20:28:17 +0100 Subject: [PATCH 2/2] test(chart): add CumulativeSpendChart tests for coverage - Extract tooltipFormatter and tooltipLabelFormatter as named exports so branch logic is directly testable without triggering Recharts events - Add tests covering array-value filtering, currency formatting, integer-day labels, and fractional crossover suppression - Raises global branch coverage above the 85% threshold --- .../__tests__/CumulativeSpendChart.test.tsx | 69 +++++++++++++++++++ .../widgets/CumulativeSpendChart.tsx | 16 ++--- .../widgets/cumulative-spend-chart-utils.ts | 22 ++++++ 3 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 frontend/apps/finance/src/features/dashboard/__tests__/CumulativeSpendChart.test.tsx create mode 100644 frontend/apps/finance/src/features/dashboard/components/widgets/cumulative-spend-chart-utils.ts diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/CumulativeSpendChart.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/CumulativeSpendChart.test.tsx new file mode 100644 index 0000000..af786c1 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/__tests__/CumulativeSpendChart.test.tsx @@ -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(); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +// 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(); + expect(screen.getByText("Cumulative Spending")).toBeInTheDocument(); + }); + + it("renders with empty data without crashing", () => { + render(); + 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(""); + }); +}); diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx index 65fecf2..cd16ba6 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx @@ -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, @@ -91,14 +95,8 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro tickFormatter={(value) => `${getCurrencySymbol(currency)}${value}`} /> - Array.isArray(value) - ? null - : [formatCurrency((value as number) * 100, currency), name] - } - labelFormatter={(label) => - Number.isInteger(label) ? `Day ${label}` : "" - } + formatter={(value, name) => tooltipFormatter(value, name as string, currency)} + labelFormatter={(label) => tooltipLabelFormatter(label)} />