From aa9a466aaceb38bdafb5c145d4c9d2ca586b669c Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:46:18 +0100 Subject: [PATCH 01/11] style: replace recency color tokens with cool blue gradient palette Decouple the Repeated Expenses chart colors from the E/D/S category palette. Use Carbon Design System sequential cyan values: - Light mode: Cyan 30/50/70 (light to dark for today/7d/30d) - Dark mode: Cyan 20/40/50 (adjusted for dark backgrounds) These are frontend-only chart concerns defined directly in globals.css, not in the brand token pipeline. --- frontend/packages/ui/src/styles/globals.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/packages/ui/src/styles/globals.css b/frontend/packages/ui/src/styles/globals.css index 005a03c..738edd5 100644 --- a/frontend/packages/ui/src/styles/globals.css +++ b/frontend/packages/ui/src/styles/globals.css @@ -82,9 +82,9 @@ --essentials: var(--brand-essentials); --desires: var(--brand-desires); --savings: var(--brand-savings); - --recency-today: var(--brand-essentials); - --recency-last-7-days: var(--brand-primary); - --recency-last-30-days: var(--brand-desires); + --recency-today: #82cfff; + --recency-last-7-days: #1192e8; + --recency-last-30-days: #00539a; --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -100,9 +100,9 @@ --essentials: var(--brand-essentials); --desires: var(--brand-desires); --savings: var(--brand-savings); - --recency-today: var(--brand-essentials); - --recency-last-7-days: var(--brand-primary); - --recency-last-30-days: var(--brand-desires); + --recency-today: #bae6ff; + --recency-last-7-days: #33b1ff; + --recency-last-30-days: #1192e8; --background: var(--brand-background); --foreground: var(--brand-foreground); --card: var(--brand-primary); From 27e0312bb95d5c06e5142d75d84cf7251f78cc29 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:46:39 +0100 Subject: [PATCH 02/11] feat(ui): add shadcn Select component to @gofin/ui Generated via shadcn CLI. The wildcard export in package.json automatically exposes it as @gofin/ui/components/select. --- .../packages/ui/src/components/select.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 frontend/packages/ui/src/components/select.tsx diff --git a/frontend/packages/ui/src/components/select.tsx b/frontend/packages/ui/src/components/select.tsx new file mode 100644 index 0000000..7fc1e65 --- /dev/null +++ b/frontend/packages/ui/src/components/select.tsx @@ -0,0 +1,190 @@ +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@gofin/ui/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} From b41ca54e8320c46482acbb600686fecbe5d88205 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:50:35 +0100 Subject: [PATCH 03/11] feat(dashboard): restructure layout with 2-col grid and chart sections - Place Spending Pace and Historical Comparison side-by-side in a responsive 2-col grid (hidden on mobile, md:grid on desktop) - Replace MonthlyTrendsSection with TrendsSection: Select dropdown to switch between Monthly Spending and Category Split, plus ToggleGroup - Add BreakdownSection: Select dropdown to switch between Spending by Tag and Repeated Expenses - Add section IDs for anchor linking (#summary, #budget-allocations, #spending-pace, #historical-comparison, #trends, #breakdown, #cumulative-spending, #recent-expenses) - Add DashboardOutline placeholder (implemented in Step 6) - Update tests to interact with Select dropdowns for chart switching - Add JSDOM polyfills for Radix pointer capture and scrollIntoView --- frontend/apps/finance/src/__tests__/setup.ts | 11 + .../__tests__/DashboardFeature.test.tsx | 53 ++++- .../dashboard/components/ActiveDashboard.tsx | 198 ++++++++++-------- .../dashboard/components/BreakdownSection.tsx | 53 +++++ .../dashboard/components/DashboardOutline.tsx | 10 + ...hlyTrendsSection.tsx => TrendsSection.tsx} | 39 +++- 6 files changed, 257 insertions(+), 107 deletions(-) create mode 100644 frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx create mode 100644 frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx rename frontend/apps/finance/src/features/dashboard/components/{MonthlyTrendsSection.tsx => TrendsSection.tsx} (50%) diff --git a/frontend/apps/finance/src/__tests__/setup.ts b/frontend/apps/finance/src/__tests__/setup.ts index f149f27..739f3fc 100644 --- a/frontend/apps/finance/src/__tests__/setup.ts +++ b/frontend/apps/finance/src/__tests__/setup.ts @@ -1 +1,12 @@ import "@testing-library/jest-dom/vitest"; + +// Radix UI Select uses pointer capture and scroll APIs not available in JSDOM. +// Provide stubs to prevent runtime errors during tests. +if (typeof Element.prototype.hasPointerCapture === "undefined") { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.setPointerCapture = () => {}; + Element.prototype.releasePointerCapture = () => {}; +} +if (typeof Element.prototype.scrollIntoView === "undefined") { + Element.prototype.scrollIntoView = () => {}; +} diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx index b4807fb..e588eaf 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -260,15 +260,26 @@ describe("DashboardFeature", () => { ...dashboardDataWithExpensesRoutes(), }); globalThis.fetch = mockApi as unknown as typeof fetch; + const user = userEvent.setup(); renderDashboard(); + // Wait for the Breakdown section to render with default "Spending by Tag" + await waitFor(() => { + expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument(); + }); + + // Switch to Repeated Expenses via the breakdown Select + const breakdownTrigger = screen.getByLabelText("Select breakdown chart"); + await user.click(breakdownTrigger); + const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" }); + await user.click(repeatedOption); + await waitFor(() => { - expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); + expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument(); }); expect(screen.getAllByText("Groceries").length).toBeGreaterThan(0); expect(screen.getAllByText("Coffee").length).toBeGreaterThan(0); - expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument(); expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Today"); expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 7 days"); expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 30 days"); @@ -288,16 +299,24 @@ describe("DashboardFeature", () => { "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; + const user = userEvent.setup(); renderDashboard(); await waitFor(() => { expect(screen.getByText("No expenses yet")).toBeInTheDocument(); }); - expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); - expect( - screen.getByText(/Not enough expense history yet/i), - ).toBeInTheDocument(); + // Switch to Repeated Expenses via the breakdown Select + const breakdownTrigger = screen.getByLabelText("Select breakdown chart"); + await user.click(breakdownTrigger); + const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" }); + await user.click(repeatedOption); + + await waitFor(() => { + expect( + screen.getByText(/Not enough expense history yet/i), + ).toBeInTheDocument(); + }); }); it("keeps other dashboard sections rendering when repeated-expenses fetch fails", async () => { @@ -309,16 +328,24 @@ describe("DashboardFeature", () => { body: { code: "INTERNAL_SERVER_ERROR", message: "Suggestions failed" }, }, }) as unknown as typeof fetch; + const user = userEvent.setup(); renderDashboard(); await waitFor(() => { expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); }); - expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); - expect( - screen.getByText("Repeated expenses are unavailable right now."), - ).toBeInTheDocument(); + // Switch to Repeated Expenses via the breakdown Select + const breakdownTrigger = screen.getByLabelText("Select breakdown chart"); + await user.click(breakdownTrigger); + const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" }); + await user.click(repeatedOption); + + await waitFor(() => { + expect( + screen.getByText("Repeated expenses are unavailable right now."), + ).toBeInTheDocument(); + }); expect(screen.queryByText("Suggestions failed")).not.toBeInTheDocument(); }); @@ -972,11 +999,15 @@ describe("DashboardFeature", () => { const { container } = renderDashboard(); await waitFor(() => { - expect(screen.getByText("Spending by Tag")).toBeInTheDocument(); + expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument(); }); + // Charts are wrapped in a hidden md:block container const hiddenCharts = container.querySelector(".hidden.md\\:block"); expect(hiddenCharts).not.toBeNull(); + // Spending Pace + Historical Comparison are in a hidden md:grid container + const gridContainer = container.querySelector(".hidden.md\\:grid"); + expect(gridContainer).not.toBeNull(); }); }); diff --git a/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx b/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx index 482e713..83ca804 100644 --- a/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx @@ -18,16 +18,16 @@ import { import { useDashboardData } from "../hooks/useDashboardData"; import { useExpenseFrecencyData } from "../hooks/useExpenseFrecencyData"; import { BudgetSettingsEditor } from "./BudgetSettingsEditor"; -import { MonthlyTrendsSection } from "./MonthlyTrendsSection"; +import { TrendsSection } from "./TrendsSection"; +import { BreakdownSection } from "./BreakdownSection"; +import { DashboardOutline } from "./DashboardOutline"; import { SummaryBar } from "./widgets/SummaryBar"; import { CategoryGauges } from "./widgets/CategoryGauges"; import { PacingIndicator } from "./widgets/PacingIndicator"; -import { TagSpendingChart } from "./widgets/TagSpendingChart"; import { CumulativeSpendChart } from "./widgets/CumulativeSpendChart"; import { RecentExpenses } from "./widgets/RecentExpenses"; import { HistoricalComparisonWidget } from "./widgets/HistoricalComparisonWidget"; import { UpcomingProRataSection } from "./widgets/UpcomingProRataSection"; -import { ExpenseFrecencyChart } from "./widgets/ExpenseFrecencyChart"; export interface ActiveDashboardProps { period: BudgetPeriod; @@ -108,111 +108,129 @@ export function ActiveDashboard({ period, user, readOnly = false }: ActiveDashbo )} {/* Summary Bar */} - - - +
+ + + +
{/* Category Gauges */} - - {data.summary && } - - - {/* Pacing Indicator */} - - {data.summary && } - +
+ + {data.summary && } + +
+ + {/* Spending Pace + Historical Comparison: side-by-side on desktop */} +
+
+ + {data.summary && } + +
+
+ + {data.comparison && ( + + )} + +
+
{/* Upcoming Pro-rata */} {data.upcomingProRata.length > 0 && ( - +
+ +
)}
{/* Charts: hidden on mobile per US-DASH-09 */}
- {/* Historical Comparison Widget */} - - {data.comparison && ( - - )} - - - {/* Monthly Trends Section */} - - {data.trendData && data.trendData.length > 0 && ( - + + {data.trendData && data.trendData.length > 0 && ( + + )} + + + + {/* Breakdown Section */} +
+ + - )} - - - {/* Tag Spending Chart */} - - {data.tagSpending.length > 0 && ( - - )} - - - {/* Repeated Expenses Chart */} - - - + +
{/* Cumulative Spend Chart */} - - {data.cumulativeData.length > 0 && ( - - )} - +
+ + {data.cumulativeData.length > 0 && ( + + )} + +
{/* Recent Expenses or Empty State */} - - {data.recentExpenses.length === 0 && !readOnly ? ( - - - -

No expenses yet

-

- Start tracking your spending by logging your first expense for this - month. -

- -
-
- ) : data.recentExpenses.length > 0 ? ( - - ) : null} -
+
+ + {data.recentExpenses.length === 0 && !readOnly ? ( + + + +

No expenses yet

+

+ Start tracking your spending by logging your first expense for this + month. +

+ +
+
+ ) : data.recentExpenses.length > 0 ? ( + + ) : null} +
+
+ + {/* Dashboard Outline (TOC) - fixed positioned, renders on xl+ viewports */} + ); } diff --git a/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx b/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx new file mode 100644 index 0000000..ddf2de4 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { TagSpending } from "../../../types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@gofin/ui/components/select"; +import { TagSpendingChart } from "./widgets/TagSpendingChart"; +import { ExpenseFrecencyChart } from "./widgets/ExpenseFrecencyChart"; +import type { ExpenseFrecencyDataState } from "../hooks/useExpenseFrecencyData"; + +type BreakdownChart = "tag-spending" | "repeated-expenses"; + +interface BreakdownSectionProps { + tagSpending: TagSpending[]; + expenseFrecencyData: ExpenseFrecencyDataState; + currency: string; +} + +export function BreakdownSection({ + tagSpending, + expenseFrecencyData, + currency, +}: BreakdownSectionProps) { + const [selectedChart, setSelectedChart] = useState("tag-spending"); + + return ( +
+
+ +
+ {selectedChart === "tag-spending" && ( + + )} + {selectedChart === "repeated-expenses" && ( + + )} +
+ ); +} diff --git a/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx new file mode 100644 index 0000000..8a1b7fc --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx @@ -0,0 +1,10 @@ +export function DashboardOutline() { + return ( + + ); +} diff --git a/frontend/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx similarity index 50% rename from frontend/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx rename to frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx index 0b313a1..82bab11 100644 --- a/frontend/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx @@ -1,21 +1,33 @@ +import { useState } from "react"; import type { TrendPoint } from "../../../types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@gofin/ui/components/select"; import { ToggleGroup, ToggleGroupItem } from "@gofin/ui/components/toggle-group"; import { SpendingTrendChart } from "./widgets/SpendingTrendChart"; import { CategorySplitChart } from "./widgets/CategorySplitChart"; -interface MonthlyTrendsSectionProps { +type TrendsChart = "monthly-spending" | "category-split"; + +interface TrendsSectionProps { trendData: TrendPoint[]; trendMonths: 6 | 12; onToggle: (months: 6 | 12) => void; currency: string; } -export function MonthlyTrendsSection({ +export function TrendsSection({ trendData, trendMonths, onToggle, currency, -}: MonthlyTrendsSectionProps) { +}: TrendsSectionProps) { + const [selectedChart, setSelectedChart] = useState("monthly-spending"); + if (trendData.length === 0) { return null; } @@ -23,7 +35,18 @@ export function MonthlyTrendsSection({ return (
-

Monthly Trends

+
- - + {selectedChart === "monthly-spending" && ( + + )} + {selectedChart === "category-split" && ( + + )}
); } From d4acd3223668508c69f974a58e1efb2859453d87 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:52:02 +0100 Subject: [PATCH 04/11] fix(dashboard): eliminate crossover shading gap in cumulative chart Add insertCrossoverPoints utility that detects sign changes between consecutive data points and linear-interpolates synthetic crossover coordinates where actual === ideal. At these points, both surplusTop and deficitTop are defined so the chart areas seamlessly transition from green to red (or vice versa) with no unshaded gap. Also fix barrel export: replace deleted MonthlyTrendsSection with TrendsSection and BreakdownSection exports. --- .../widgets/CumulativeSpendChart.tsx | 38 +++++-- .../finance/src/features/dashboard/index.ts | 3 +- .../__tests__/insertCrossoverPoints.test.ts | 103 ++++++++++++++++++ .../finance/src/lib/insertCrossoverPoints.ts | 52 +++++++++ 4 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 frontend/apps/finance/src/lib/__tests__/insertCrossoverPoints.test.ts create mode 100644 frontend/apps/finance/src/lib/insertCrossoverPoints.ts 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 5943721..f9392ed 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx @@ -1,5 +1,6 @@ import { formatCurrency, getCurrencySymbol } from "@gofin/core"; import type { CumulativeSpendPoint } from "../../../../types"; +import { insertCrossoverPoints } from "../../../../lib/insertCrossoverPoints"; import { Card, CardContent, @@ -23,18 +24,35 @@ interface CumulativeSpendChartProps { } export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartProps) { - const chartData = data.map((point) => { - const actual = point.actual / 100; - const ideal = point.ideal / 100; - const underBudget = actual <= ideal; + // Convert from cents to dollars and insert crossover interpolation points + const basePoints = data.map((point) => ({ + day: point.day, + actual: point.actual / 100, + ideal: point.ideal / 100, + })); + + const interpolatedPoints = insertCrossoverPoints(basePoints); + + const chartData = interpolatedPoints.map((point) => { + const underBudget = point.actual <= point.ideal; + + if (point.isCrossover) { + // At crossover, define both areas to terminate/start seamlessly + return { + day: point.day, + actual: point.actual, + ideal: point.ideal, + surplusTop: point.actual, + deficitTop: point.actual, + }; + } + return { day: point.day, - actual, - ideal, - surplusBase: underBudget ? actual : undefined, - surplusTop: underBudget ? ideal : undefined, - deficitBase: !underBudget ? ideal : undefined, - deficitTop: !underBudget ? actual : undefined, + actual: point.actual, + ideal: point.ideal, + surplusTop: underBudget ? point.ideal : undefined, + deficitTop: !underBudget ? point.actual : undefined, }; }); diff --git a/frontend/apps/finance/src/features/dashboard/index.ts b/frontend/apps/finance/src/features/dashboard/index.ts index c2dbedd..1f0f3b9 100644 --- a/frontend/apps/finance/src/features/dashboard/index.ts +++ b/frontend/apps/finance/src/features/dashboard/index.ts @@ -1,3 +1,4 @@ export { DashboardFeature } from "./DashboardFeature"; export { ActiveDashboard } from "./components/ActiveDashboard"; -export { MonthlyTrendsSection } from "./components/MonthlyTrendsSection"; +export { TrendsSection } from "./components/TrendsSection"; +export { BreakdownSection } from "./components/BreakdownSection"; diff --git a/frontend/apps/finance/src/lib/__tests__/insertCrossoverPoints.test.ts b/frontend/apps/finance/src/lib/__tests__/insertCrossoverPoints.test.ts new file mode 100644 index 0000000..fa2a4b9 --- /dev/null +++ b/frontend/apps/finance/src/lib/__tests__/insertCrossoverPoints.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { insertCrossoverPoints } from "../insertCrossoverPoints"; + +describe("insertCrossoverPoints", () => { + it("returns empty array for empty input", () => { + expect(insertCrossoverPoints([])).toEqual([]); + }); + + it("returns single point unchanged", () => { + const points = [{ day: 1, actual: 100, ideal: 200 }]; + expect(insertCrossoverPoints(points)).toEqual(points); + }); + + it("returns two points unchanged when no crossover occurs", () => { + const points = [ + { day: 1, actual: 100, ideal: 200 }, + { day: 2, actual: 150, ideal: 250 }, + ]; + expect(insertCrossoverPoints(points)).toEqual(points); + }); + + it("inserts interpolated point when actual crosses above ideal", () => { + const points = [ + { day: 1, actual: 100, ideal: 200 }, + { day: 2, actual: 300, ideal: 200 }, + ]; + const result = insertCrossoverPoints(points); + + expect(result).toHaveLength(3); + // Crossover point: actual goes from 100 to 300 (+200), ideal stays at 200 + // At crossover: actual === ideal === 200 + // Linear interp: (200 - 100) / (300 - 100) = 0.5 of the interval + // day = 1 + 0.5 * (2 - 1) = 1.5 + expect(result[1]).toEqual({ + day: 1.5, + actual: 200, + ideal: 200, + isCrossover: true, + }); + }); + + it("inserts interpolated point when actual crosses below ideal", () => { + const points = [ + { day: 5, actual: 500, ideal: 300 }, + { day: 10, actual: 200, ideal: 400 }, + ]; + const result = insertCrossoverPoints(points); + + expect(result).toHaveLength(3); + // diff at day 5: 500 - 300 = 200 (actual > ideal) + // diff at day 10: 200 - 400 = -200 (actual < ideal) + // crossover fraction: 200 / (200 + 200) = 0.5 + // day = 5 + 0.5 * (10 - 5) = 7.5 + // actual at crossover: 500 + 0.5 * (200 - 500) = 350 + // ideal at crossover: 300 + 0.5 * (400 - 300) = 350 + expect(result[1]).toEqual({ + day: 7.5, + actual: 350, + ideal: 350, + isCrossover: true, + }); + }); + + it("inserts multiple crossover points for multiple crossings", () => { + const points = [ + { day: 1, actual: 100, ideal: 200 }, + { day: 2, actual: 300, ideal: 200 }, + { day: 3, actual: 100, ideal: 200 }, + ]; + const result = insertCrossoverPoints(points); + + // Two crossovers: between day 1-2 and day 2-3 + expect(result).toHaveLength(5); + expect(result[1].isCrossover).toBe(true); + expect(result[3].isCrossover).toBe(true); + }); + + it("does not insert crossover point when actual equals ideal at a data point", () => { + const points = [ + { day: 1, actual: 100, ideal: 200 }, + { day: 2, actual: 200, ideal: 200 }, + { day: 3, actual: 300, ideal: 200 }, + ]; + const result = insertCrossoverPoints(points); + + // At day 2, actual === ideal exactly. No synthetic point needed between 1-2 + // because the sign doesn't cross (it goes from negative to zero to positive). + // Between day 1 and 2: diff goes from -100 to 0 (no sign change, zero is not crossing) + // Between day 2 and 3: diff goes from 0 to +100 (no sign change) + expect(result).toHaveLength(3); + expect(result).toEqual(points); + }); + + it("preserves original points without mutation", () => { + const points = [ + { day: 1, actual: 100, ideal: 200 }, + { day: 2, actual: 300, ideal: 200 }, + ]; + const original = JSON.parse(JSON.stringify(points)); + insertCrossoverPoints(points); + expect(points).toEqual(original); + }); +}); diff --git a/frontend/apps/finance/src/lib/insertCrossoverPoints.ts b/frontend/apps/finance/src/lib/insertCrossoverPoints.ts new file mode 100644 index 0000000..0715647 --- /dev/null +++ b/frontend/apps/finance/src/lib/insertCrossoverPoints.ts @@ -0,0 +1,52 @@ +export interface ChartPoint { + day: number; + actual: number; + ideal: number; + isCrossover?: boolean; +} + +/** + * Insert synthetic interpolated data points at crossover coordinates where + * the "actual" line crosses the "ideal" line. This eliminates shading gaps + * in area charts that use separate fill regions for above/below states. + * + * For consecutive points where (actual - ideal) changes sign, the function + * linear-interpolates the exact fractional day where actual === ideal and + * inserts a synthetic point marked with `isCrossover: true`. + */ +export function insertCrossoverPoints(points: ChartPoint[]): ChartPoint[] { + if (points.length <= 1) return points; + + const result: ChartPoint[] = []; + + for (let i = 0; i < points.length; i++) { + const current = points[i]; + + if (i > 0) { + const prev = points[i - 1]; + const prevDiff = prev.actual - prev.ideal; + const currDiff = current.actual - current.ideal; + + // Sign change means a crossover occurred between these two points. + // Exclude zero values: if either endpoint is exactly on the line, + // no synthetic point is needed. + if (prevDiff !== 0 && currDiff !== 0 && prevDiff * currDiff < 0) { + // Linear interpolation: find fraction t where diff = 0 + const t = Math.abs(prevDiff) / (Math.abs(prevDiff) + Math.abs(currDiff)); + const crossoverDay = prev.day + t * (current.day - prev.day); + const crossoverValue = prev.actual + t * (current.actual - prev.actual); + + result.push({ + day: crossoverDay, + actual: crossoverValue, + ideal: crossoverValue, + isCrossover: true, + }); + } + } + + result.push(current); + } + + return result; +} From 54e834c78eecca337cd5e0d992cca9d9aec1b342 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:52:30 +0100 Subject: [PATCH 05/11] feat(dashboard): add diamond marker at current day on cumulative chart Render a ReferenceDot with a custom diamond polygon shape at the current day's position on the 'Actual' line. The marker uses var(--primary) fill with a var(--background) stroke for visibility. --- .../widgets/CumulativeSpendChart.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 f9392ed..8a8fbaa 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx @@ -16,6 +16,7 @@ import { Tooltip, ResponsiveContainer, ComposedChart, + ReferenceDot, } from "recharts"; interface CumulativeSpendChartProps { @@ -24,6 +25,8 @@ interface CumulativeSpendChartProps { } export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartProps) { + const currentDay = new Date().getDate(); + // Convert from cents to dollars and insert crossover interpolation points const basePoints = data.map((point) => ({ day: point.day, @@ -113,6 +116,30 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro dot={false} name="Actual" /> + {(() => { + const todayPoint = chartData.find( + (point) => point.day === currentDay && point.actual != null, + ); + if (!todayPoint) return null; + return ( + { + const { cx = 0, cy = 0 } = props; + const size = 6; + return ( + + ); + }} + /> + ); + })()} From ca1f60fef728513c9eb6b426d20308c2a85523ea Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 18:56:44 +0100 Subject: [PATCH 06/11] feat(dashboard): add DashboardOutline TOC sidebar with smooth scroll Implement a fixed-position Table of Contents on the right edge of the viewport (visible on xl+ breakpoints, hidden below). Links use anchor hrefs to smooth-scroll to corresponding section IDs. Also add scroll-behavior: smooth to html element in globals.css. Update existing test assertions to handle duplicate text from TOC links coexisting with card titles. Rewrite monthly-trends test for the new TrendsSection component API (Select-based chart switching). --- .../src/__tests__/monthly-trends.test.tsx | 55 ++++++++++----- .../DashboardFeature-budget-editor.test.tsx | 6 +- .../__tests__/DashboardFeature.test.tsx | 12 ++-- .../__tests__/DashboardOutline.test.tsx | 54 +++++++++++++++ .../dashboard/components/DashboardOutline.tsx | 69 ++++++++++++++++++- frontend/packages/ui/src/styles/globals.css | 1 + 6 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx diff --git a/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx b/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx index 59e7c55..3c8bab9 100644 --- a/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx +++ b/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MonthlyTrendsSection } from "@/features/dashboard"; +import { TrendsSection } from "@/features/dashboard"; import type { TrendPoint } from "@/types"; const mockTrendData: TrendPoint[] = [ @@ -79,30 +79,28 @@ const mockTrendData: TrendPoint[] = [ }, ]; -describe("MonthlyTrendsSection", () => { - it("renders section with heading and charts when data is provided", () => { - const onToggle = () => {}; +describe("TrendsSection", () => { + it("renders Select with Monthly Spending as default and the spending chart", () => { render( - {}} currency="USD" />, ); - expect(screen.getByText("Monthly Trends")).toBeInTheDocument(); - expect(screen.getByText("Monthly Spending")).toBeInTheDocument(); - expect(screen.getByText("Category Split")).toBeInTheDocument(); + expect(screen.getByLabelText("Select trend chart")).toBeInTheDocument(); + // Monthly Spending appears in both the Select trigger and chart title + expect(screen.getAllByText("Monthly Spending").length).toBeGreaterThanOrEqual(1); }); it("renders nothing when data is empty", () => { - const onToggle = () => {}; const { container } = render( - {}} currency="USD" />, ); @@ -111,12 +109,11 @@ describe("MonthlyTrendsSection", () => { }); it("renders toggle group with 6M and 12M options", () => { - const onToggle = () => {}; render( - {}} currency="USD" />, ); @@ -133,7 +130,7 @@ describe("MonthlyTrendsSection", () => { }; render( - { }); it("marks current toggle value as active", () => { - const onToggle = () => {}; render( - {}} currency="USD" />, ); @@ -164,4 +160,25 @@ describe("MonthlyTrendsSection", () => { const toggle6M = screen.getByRole("radio", { name: "6 months" }); expect(toggle6M).toHaveAttribute("data-state", "off"); }); + + it("switches to Category Split chart when selected", async () => { + const user = userEvent.setup(); + render( + {}} + currency="USD" + />, + ); + + // Open Select and choose Category Split + const trigger = screen.getByLabelText("Select trend chart"); + await user.click(trigger); + const categorySplitOption = await screen.findByRole("option", { name: "Category Split" }); + await user.click(categorySplitOption); + + // Category Split appears in both Select trigger and chart title + expect(screen.getAllByText("Category Split").length).toBeGreaterThanOrEqual(2); + }); }); diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-budget-editor.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-budget-editor.test.tsx index 6121e5e..fe1e776 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-budget-editor.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-budget-editor.test.tsx @@ -247,11 +247,11 @@ describe("DashboardFeature - Budget Settings Editor Save", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Monthly Trends")).toBeInTheDocument(); + expect(screen.getByLabelText("Select trend chart")).toBeInTheDocument(); }); - expect(screen.getByText("Monthly Spending")).toBeInTheDocument(); - expect(screen.getByText("Category Split")).toBeInTheDocument(); + // Default chart is "Monthly Spending" shown in Select trigger and chart title + expect(screen.getAllByText("Monthly Spending").length).toBeGreaterThanOrEqual(1); }); it("shows network error message when budget save has connection failure", async () => { diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx index e588eaf..b79f33d 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -332,7 +332,7 @@ describe("DashboardFeature", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); + expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1); }); // Switch to Repeated Expenses via the breakdown Select @@ -739,7 +739,7 @@ describe("DashboardFeature", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); + expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1); }); expect(screen.getAllByText("Groceries").length).toBeGreaterThan(0); @@ -756,7 +756,7 @@ describe("DashboardFeature", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); + expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1); }); const viewAllLink = screen.getByRole("link", { name: /view all/i }); @@ -771,7 +771,7 @@ describe("DashboardFeature", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); + expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1); }); // totalSpent from summary: 54500 cents = $545.00 @@ -886,7 +886,7 @@ describe("DashboardFeature", () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText("Spending Pace")).toBeInTheDocument(); + expect(screen.getAllByText("Spending Pace").length).toBeGreaterThanOrEqual(1); }); expect(screen.getByText("Daily Average")).toBeInTheDocument(); @@ -1088,7 +1088,7 @@ describe("DashboardFeature", () => { expect(screen.getByTestId("historical-comparison")).toBeInTheDocument(); }); - expect(screen.getByText("Historical Comparison")).toBeInTheDocument(); + expect(screen.getAllByText("Historical Comparison").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Current Period")).toBeInTheDocument(); expect(screen.getByText("Previous Period")).toBeInTheDocument(); expect(screen.getByText("$480.00")).toBeInTheDocument(); diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx new file mode 100644 index 0000000..1ea9c64 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DashboardOutline } from "../components/DashboardOutline"; + +describe("DashboardOutline", () => { + it("renders a nav with Dashboard sections aria-label", () => { + render(); + expect(screen.getByRole("navigation", { name: "Dashboard sections" })).toBeInTheDocument(); + }); + + it("has responsive visibility classes (hidden on small, visible on xl)", () => { + const { container } = render(); + const nav = container.querySelector("nav"); + expect(nav?.className).toContain("hidden"); + expect(nav?.className).toContain("xl:block"); + }); + + it("has fixed positioning classes", () => { + const { container } = render(); + const nav = container.querySelector("nav"); + expect(nav?.className).toContain("fixed"); + }); + + it("renders all top-level section links", () => { + render(); + const links = screen.getAllByRole("link"); + const linkTexts = links.map((link) => link.textContent); + + expect(linkTexts).toContain("Summary"); + expect(linkTexts).toContain("Trends"); + expect(linkTexts).toContain("Breakdown"); + expect(linkTexts).toContain("Cumulative Spending"); + expect(linkTexts).toContain("Recent Expenses"); + }); + + it("renders nested child links under Summary", () => { + render(); + const links = screen.getAllByRole("link"); + const linkTexts = links.map((link) => link.textContent); + + expect(linkTexts).toContain("Budget Allocations"); + expect(linkTexts).toContain("Spending Pace"); + expect(linkTexts).toContain("Historical Comparison"); + }); + + it("links have correct href attributes for anchor navigation", () => { + render(); + + expect(screen.getByRole("link", { name: "Summary" })).toHaveAttribute("href", "#summary"); + expect(screen.getByRole("link", { name: "Spending Pace" })).toHaveAttribute("href", "#spending-pace"); + expect(screen.getByRole("link", { name: "Cumulative Spending" })).toHaveAttribute("href", "#cumulative-spending"); + expect(screen.getByRole("link", { name: "Recent Expenses" })).toHaveAttribute("href", "#recent-expenses"); + }); +}); diff --git a/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx index 8a1b7fc..bc338b6 100644 --- a/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx @@ -1,10 +1,77 @@ +const TOC_ITEMS = [ + { + label: "Summary", + href: "#summary", + children: [ + { label: "Budget Allocations", href: "#budget-allocations" }, + { label: "Spending Pace", href: "#spending-pace" }, + { label: "Historical Comparison", href: "#historical-comparison" }, + ], + }, + { + label: "Trends", + href: "#trends", + children: [ + { label: "Monthly Spending", href: "#trends" }, + { label: "Category Split", href: "#trends" }, + ], + }, + { + label: "Breakdown", + href: "#breakdown", + children: [ + { label: "Spending by Tag", href: "#breakdown" }, + { label: "Repeated Expenses", href: "#breakdown" }, + ], + }, + { label: "Cumulative Spending", href: "#cumulative-spending" }, + { label: "Recent Expenses", href: "#recent-expenses" }, +] as const; + +interface TocItem { + label: string; + href: string; + children?: readonly { label: string; href: string }[]; +} + export function DashboardOutline() { return ( ); } + +function TocEntry({ item }: { item: TocItem }) { + return ( +
  • + + {item.label} + + {item.children && item.children.length > 0 && ( + + )} +
  • + ); +} diff --git a/frontend/packages/ui/src/styles/globals.css b/frontend/packages/ui/src/styles/globals.css index 738edd5..f539283 100644 --- a/frontend/packages/ui/src/styles/globals.css +++ b/frontend/packages/ui/src/styles/globals.css @@ -145,5 +145,6 @@ } html { @apply font-sans; + scroll-behavior: smooth; } } From 49cea0bcc154d0a24c0ac264e1daa1793fb73d6d Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 19:04:17 +0100 Subject: [PATCH 07/11] refactor(dashboard): add BreakdownSection test and simplify ReferenceDot - Add dedicated BreakdownSection.test.tsx with 4 tests covering default chart display, chart switching, and empty state - Replace IIFE pattern for ReferenceDot with pre-computed todayPoint variable and conditional JSX rendering --- .../__tests__/BreakdownSection.test.tsx | 80 +++++++++++++++++++ .../widgets/CumulativeSpendChart.tsx | 46 +++++------ 2 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx new file mode 100644 index 0000000..d210709 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { BreakdownSection } from "../components/BreakdownSection"; +import type { ExpenseFrecencyDataState } from "../hooks/useExpenseFrecencyData"; + +const mockTagSpending = [ + { tagId: "tag-food", tagName: "Food", amount: 50000, percentOfTotal: 91.74 }, + { tagId: "tag-social", tagName: "Social", amount: 4500, percentOfTotal: 8.26 }, +]; + +const mockFrecencyData: ExpenseFrecencyDataState = { + status: "success", + suggestions: [ + { + name: "Groceries", + amount: 50000, + currency: "USD", + expenseType: "essentials", + frequency: 114, + lastUsedAt: "2026-05-02T10:00:00Z", + recencyBucket: "last_7_days", + frecencyScore: 145, + tagId: "tag-food", + }, + ], + errorMessage: null, +}; + +function renderBreakdown(props?: Partial[0]>) { + return render( + + + , + ); +} + +describe("BreakdownSection", () => { + it("renders Select with 'Spending by Tag' as default", () => { + renderBreakdown(); + expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument(); + expect(screen.getAllByText("Spending by Tag").length).toBeGreaterThanOrEqual(1); + }); + + it("shows TagSpendingChart by default", () => { + renderBreakdown(); + // TagSpendingChart renders a card with title "Spending by Tag" + expect(screen.getAllByText("Spending by Tag").length).toBeGreaterThanOrEqual(2); + }); + + it("switches to Repeated Expenses chart when selected", async () => { + const user = userEvent.setup(); + renderBreakdown(); + + const trigger = screen.getByLabelText("Select breakdown chart"); + await user.click(trigger); + const option = await screen.findByRole("option", { name: "Repeated Expenses" }); + await user.click(option); + + // ExpenseFrecencyChart renders with success state content + expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument(); + // Tag spending chart card should no longer be present + expect(screen.queryByText("Spending by Tag")).toBeNull(); + }); + + it("shows empty state when tag spending is empty and frecency is empty", () => { + renderBreakdown({ + tagSpending: [], + expenseFrecencyData: { status: "empty", suggestions: [], errorMessage: null }, + }); + // TagSpendingChart still renders its card even with no data (no tags shown) + expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument(); + }); +}); 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 8a8fbaa..e10b8ba 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/CumulativeSpendChart.tsx @@ -59,6 +59,10 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro }; }); + const todayPoint = chartData.find( + (point) => point.day === currentDay && point.actual != null, + ); + return ( @@ -116,30 +120,24 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro dot={false} name="Actual" /> - {(() => { - const todayPoint = chartData.find( - (point) => point.day === currentDay && point.actual != null, - ); - if (!todayPoint) return null; - return ( - { - const { cx = 0, cy = 0 } = props; - const size = 6; - return ( - - ); - }} - /> - ); - })()} + {todayPoint && ( + { + const { cx = 0, cy = 0 } = props; + const size = 6; + return ( + + ); + }} + /> + )} From 89e635a66af357449290c820b396f91ff5c9e9e3 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 20:12:24 +0100 Subject: [PATCH 08/11] fix(mfe): run shell in dev compose - set NODE_ENV=development for the bind-mounted shell container - start the shell workspace dev server so it does not require production build artifacts - keep npm ci in the dev command so container dependencies match the mounted lockfile --- docker-compose.dev.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fe76503..547a360 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,6 +5,8 @@ services: mfe: environment: - COOKIE_SECURE=false + - NODE_ENV=development + command: sh -lc "cd /app && npm ci && npm run dev --workspace=@gofin/shell" volumes: - ./frontend:/app - /app/node_modules From 82fdf03ad0d854756cb04dcb8005a3ca78804ff8 Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 20:13:35 +0100 Subject: [PATCH 09/11] style(dashboard): bump chart Select dropdowns from text-sm to text-base Use Tailwind's text-base utility class on SelectTrigger and SelectItem to increase readability of the chart section dropdowns. --- .../src/features/dashboard/components/BreakdownSection.tsx | 6 +++--- .../src/features/dashboard/components/TrendsSection.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx b/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx index ddf2de4..90f270f 100644 --- a/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/BreakdownSection.tsx @@ -33,12 +33,12 @@ export function BreakdownSection({ value={selectedChart} onValueChange={(value) => setSelectedChart(value as BreakdownChart)} > - + - Spending by Tag - Repeated Expenses + Spending by Tag + Repeated Expenses diff --git a/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx index 82bab11..488711b 100644 --- a/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx @@ -39,12 +39,12 @@ export function TrendsSection({ value={selectedChart} onValueChange={(value) => setSelectedChart(value as TrendsChart)} > - + - Monthly Spending - Category Split + Monthly Spending + Category Split Date: Mon, 8 Jun 2026 20:18:23 +0100 Subject: [PATCH 10/11] style(dashboard): equalize Spending Pace and Historical Comparison height Add h-full to both Card wrappers so they stretch to the same row height within the md:grid-cols-2 container. --- .../dashboard/components/widgets/HistoricalComparisonWidget.tsx | 2 +- .../features/dashboard/components/widgets/PacingIndicator.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx index 4e2efe7..434f1dc 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx @@ -25,7 +25,7 @@ export function HistoricalComparisonWidget({ const isOnlyOnePeriod = comparison.previousSpent === 0 && comparison.changePercent === 0; return ( - +
    diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/PacingIndicator.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/PacingIndicator.tsx index 40098a9..0f837f5 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/PacingIndicator.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/PacingIndicator.tsx @@ -23,7 +23,7 @@ export function PacingIndicator({ summary, currency }: PacingIndicatorProps) { const overAmount = isOverBudget ? summary.totalSpent - summary.totalBudget : 0; return ( - +
    From 242b345285040a3f6c1a46ddb448763c2d26bf7a Mon Sep 17 00:00:00 2001 From: thompson Date: Mon, 8 Jun 2026 20:41:23 +0100 Subject: [PATCH 11/11] test(settings): stabilize export rate limit tests - Replace hardcoded retry dates with dynamically generated future timestamps. - Add helper utilities for retryAfter and date formatting in test fixtures. - Restore real timers after each test to prevent timer state leakage. --- .../__tests__/ExportDataSection.test.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx index e0f5fa4..31738ef 100644 --- a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx +++ b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx @@ -22,11 +22,22 @@ const emptyListResponse = { body: { data: [], total: 0, page: 1, pageSize: 50, hasMore: false }, }; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function buildFutureRetryAfter(): string { + return new Date(Date.now() + ONE_DAY_MS).toISOString(); +} + +function formatDateOnly(isoDate: string): string { + return isoDate.slice(0, 10); +} + describe("ExportDataSection", () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -295,6 +306,7 @@ describe("ExportDataSection", () => { it("handles 429 rate limit gracefully and shows cooldown", async () => { const user = userEvent.setup(); + const retryAfter = buildFutureRetryAfter(); global.fetch = createMockApi({ "/api/datarights/exports": mockSequence([ @@ -305,8 +317,8 @@ describe("ExportDataSection", () => { status: 429, body: { code: "RATE_LIMITED", - message: "Export limit reached. You can request another export after 2026-06-08.", - retryAfter: "2026-06-08T14:30:00Z", + message: `Export limit reached. You can request another export after ${formatDateOnly(retryAfter)}.`, + retryAfter, }, }, ]), @@ -563,6 +575,7 @@ describe("ExportDataSection", () => { it("handles 429 with full ISO datetime in message (includes time component)", async () => { const user = userEvent.setup(); + const retryAfter = buildFutureRetryAfter(); global.fetch = createMockApi({ "/api/datarights/exports": mockSequence([ @@ -573,8 +586,8 @@ describe("ExportDataSection", () => { status: 429, body: { code: "RATE_LIMITED", - message: "Export limit reached. Next available: 2026-06-08T14:30:00Z", - retryAfter: "2026-06-08T14:30:00Z", + message: `Export limit reached. Next available: ${retryAfter}`, + retryAfter, }, }, ]),