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 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/__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__/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/__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 b4807fb..b79f33d 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.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1); }); - 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(); }); @@ -712,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); @@ -729,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 }); @@ -744,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 @@ -859,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(); @@ -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(); }); }); @@ -1057,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/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..90f270f --- /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..bc338b6 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx @@ -0,0 +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/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx b/frontend/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx deleted file mode 100644 index 0b313a1..0000000 --- a/frontend/apps/finance/src/features/dashboard/components/MonthlyTrendsSection.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { TrendPoint } from "../../../types"; -import { ToggleGroup, ToggleGroupItem } from "@gofin/ui/components/toggle-group"; -import { SpendingTrendChart } from "./widgets/SpendingTrendChart"; -import { CategorySplitChart } from "./widgets/CategorySplitChart"; - -interface MonthlyTrendsSectionProps { - trendData: TrendPoint[]; - trendMonths: 6 | 12; - onToggle: (months: 6 | 12) => void; - currency: string; -} - -export function MonthlyTrendsSection({ - trendData, - trendMonths, - onToggle, - currency, -}: MonthlyTrendsSectionProps) { - if (trendData.length === 0) { - return null; - } - - return ( -
    -
    -

    Monthly Trends

    - { - if (value === "6" || value === "12") { - onToggle(Number(value) as 6 | 12); - } - }} - size="sm" - > - - 6M - - - 12M - - -
    - - -
    - ); -} diff --git a/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx new file mode 100644 index 0000000..488711b --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/TrendsSection.tsx @@ -0,0 +1,76 @@ +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"; + +type TrendsChart = "monthly-spending" | "category-split"; + +interface TrendsSectionProps { + trendData: TrendPoint[]; + trendMonths: 6 | 12; + onToggle: (months: 6 | 12) => void; + currency: string; +} + +export function TrendsSection({ + trendData, + trendMonths, + onToggle, + currency, +}: TrendsSectionProps) { + const [selectedChart, setSelectedChart] = useState("monthly-spending"); + + if (trendData.length === 0) { + return null; + } + + return ( +
    +
    + + { + if (value === "6" || value === "12") { + onToggle(Number(value) as 6 | 12); + } + }} + size="sm" + > + + 6M + + + 12M + + +
    + {selectedChart === "monthly-spending" && ( + + )} + {selectedChart === "category-split" && ( + + )} +
    + ); +} 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..e10b8ba 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, @@ -15,6 +16,7 @@ import { Tooltip, ResponsiveContainer, ComposedChart, + ReferenceDot, } from "recharts"; interface CumulativeSpendChartProps { @@ -23,21 +25,44 @@ 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; + const currentDay = new Date().getDate(); + + // 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, }; }); + const todayPoint = chartData.find( + (point) => point.day === currentDay && point.actual != null, + ); + return ( @@ -95,6 +120,24 @@ export function CumulativeSpendChart({ data, currency }: CumulativeSpendChartPro dot={false} name="Actual" /> + {todayPoint && ( + { + const { cx = 0, cy = 0 } = props; + const size = 6; + return ( + + ); + }} + /> + )} 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 ( - +
    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/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, }, }, ]), 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; +} 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, +} diff --git a/frontend/packages/ui/src/styles/globals.css b/frontend/packages/ui/src/styles/globals.css index 005a03c..f539283 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); @@ -145,5 +145,6 @@ } html { @apply font-sans; + scroll-behavior: smooth; } }