From a4000ea0a84a5006ab2ec2de97e594982fe86c18 Mon Sep 17 00:00:00 2001 From: thompson Date: Fri, 5 Jun 2026 23:18:18 +0100 Subject: [PATCH 1/4] feat(finance): add repeated expenses dashboard chart --- .../__tests__/DashboardFeature.test.tsx | 89 +++++++++++ .../__tests__/useExpenseFrecencyData.test.ts | 99 +++++++++++++ .../dashboard/components/ActiveDashboard.tsx | 8 + .../widgets/ExpenseFrecencyChart.tsx | 138 ++++++++++++++++++ .../dashboard/hooks/useExpenseFrecencyData.ts | 64 ++++++++ 5 files changed, 398 insertions(+) create mode 100644 frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts create mode 100644 frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx create mode 100644 frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts 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 7efb3c5..df14d4c 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -71,6 +71,31 @@ const testCumulativeData = Array.from({ length: 31 }, (_, index) => ({ ideal: Math.round((300000 / 31) * (index + 1)), })); +const testExpenseSuggestions = [ + { + name: "Groceries", + amount: 50000, + currency: "USD", + expenseType: "essentials" as const, + tagId: "tag-food", + frequency: 114, + lastUsedAt: "2026-05-02T10:00:00Z", + recencyBucket: "last_7_days" as const, + frecencyScore: 145, + }, + { + name: "Coffee", + amount: 4500, + currency: "USD", + expenseType: "desires" as const, + tagId: "tag-social", + frequency: 42, + lastUsedAt: "2026-05-01T09:00:00Z", + recencyBucket: "today" as const, + frecencyScore: 90, + }, +]; + const testExpenses = [ buildExpense({ id: "exp-1", @@ -127,6 +152,9 @@ function dashboardDataEmptyRoutes() { }, "/api/finance/spending/by-tag": { body: { tagSpending: [] } }, "/api/finance/spending/cumulative": { body: { points: [] } }, + "/api/expenses/suggestions": { + body: { data: [], total: 0, page: 1, pageSize: 10, hasMore: false }, + }, "/api/expenses": { body: { data: [], total: 0, page: 1, pageSize: 5, hasMore: false } }, "/api/finance/spending/comparison": { status: 404, @@ -143,6 +171,9 @@ function dashboardDataWithExpensesRoutes() { "/api/finance/summary": { body: { summary: testSummary } }, "/api/finance/spending/by-tag": { body: { tagSpending: testTagSpending } }, "/api/finance/spending/cumulative": { body: { points: testCumulativeData } }, + "/api/expenses/suggestions": { + body: { data: testExpenseSuggestions, total: 2, page: 1, pageSize: 10, hasMore: false }, + }, "/api/expenses": { body: { data: testExpenses, total: 2, page: 1, pageSize: 5, hasMore: false }, }, @@ -223,6 +254,64 @@ describe("DashboardFeature", () => { expect(ctaLink).toHaveAttribute("href", "/expenses/new"); }); + it("renders repeated-expenses chart with frequency and recency context", async () => { + const mockApi = createMockApi({ + "/api/finance/periods/current": { body: { period: testPeriod } }, + ...dashboardDataWithExpensesRoutes(), + }); + global.fetch = mockApi as unknown as typeof fetch; + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); + }); + + expect(screen.getByText("Groceries")).toBeInTheDocument(); + expect(screen.getByText("Coffee")).toBeInTheDocument(); + expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument(); + expect( + mockApi._calls.some((call) => + call.url.includes("/api/expenses/suggestions?page=1&pageSize=10"), + ), + ).toBe(true); + }); + + it("shows a local repeated-expenses empty state without changing dashboard empty expense behavior", async () => { + global.fetch = createMockApi({ + "/api/finance/periods/current": { body: { period: testPeriod } }, + ...dashboardDataEmptyRoutes(), + }) as unknown as typeof fetch; + 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(); + }); + + it("keeps other dashboard sections rendering when repeated-expenses fetch fails", async () => { + global.fetch = createMockApi({ + "/api/finance/periods/current": { body: { period: testPeriod } }, + ...dashboardDataWithExpensesRoutes(), + "/api/expenses/suggestions": { + status: 500, + body: { code: "INTERNAL_SERVER_ERROR", message: "Suggestions failed" }, + }, + }) as unknown as typeof fetch; + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); + }); + + expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); + expect(screen.getByText("Suggestions failed")).toBeInTheDocument(); + }); + it("displays currency symbol from user profile", async () => { global.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts new file mode 100644 index 0000000..7a7dac8 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createMockApi } from "@gofin/test-utils"; +import { useExpenseFrecencyData } from "../hooks/useExpenseFrecencyData"; + +const suggestions = [ + { + name: "Groceries", + amount: 50000, + currency: "USD", + expenseType: "essentials" as const, + tagId: "tag-food", + frequency: 114, + lastUsedAt: "2026-05-02T10:00:00Z", + recencyBucket: "last_7_days" as const, + frecencyScore: 145, + }, + { + name: "Coffee", + amount: 4500, + currency: "USD", + expenseType: "desires" as const, + tagId: "tag-social", + frequency: 42, + lastUsedAt: "2026-05-01T09:00:00Z", + recencyBucket: "today" as const, + frecencyScore: 90, + }, +]; + +describe("useExpenseFrecencyData", () => { + it("fetches page 1 suggestions and exposes success state", async () => { + const mockApi = createMockApi({ + "/api/expenses/suggestions": { + body: { data: suggestions, total: 2, page: 1, pageSize: 2, hasMore: false }, + }, + }); + globalThis.fetch = mockApi as unknown as typeof fetch; + + const { result } = renderHook(() => useExpenseFrecencyData({ pageSize: 2 })); + + expect(result.current.status).toBe("loading"); + await waitFor(() => { + expect(result.current.status).toBe("success"); + }); + + expect(result.current.suggestions).toEqual(suggestions); + expect(result.current.errorMessage).toBeNull(); + expect(mockApi._calls[0].url).toContain("/api/expenses/suggestions?page=1&pageSize=2"); + }); + + it("exposes empty state when no suggestions are returned", async () => { + globalThis.fetch = createMockApi({ + "/api/expenses/suggestions": { + body: { data: [], total: 0, page: 1, pageSize: 10, hasMore: false }, + }, + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useExpenseFrecencyData()); + + await waitFor(() => { + expect(result.current.status).toBe("empty"); + }); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.errorMessage).toBeNull(); + }); + + it("exposes error state when the request fails", async () => { + globalThis.fetch = createMockApi({ + "/api/expenses/suggestions": { + status: 500, + body: { code: "INTERNAL_SERVER_ERROR", message: "Suggestions failed" }, + }, + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useExpenseFrecencyData()); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + }); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.errorMessage).toBe("Suggestions failed"); + }); + + it("aborts the in-flight request on unmount", () => { + let capturedSignal: AbortSignal | undefined; + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }) as unknown as typeof fetch; + + const { unmount } = renderHook(() => useExpenseFrecencyData()); + unmount(); + + expect(capturedSignal?.aborted).toBe(true); + }); +}); diff --git a/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx b/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx index 56201ff..482e713 100644 --- a/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/ActiveDashboard.tsx @@ -16,6 +16,7 @@ import { Settings2, } from "lucide-react"; import { useDashboardData } from "../hooks/useDashboardData"; +import { useExpenseFrecencyData } from "../hooks/useExpenseFrecencyData"; import { BudgetSettingsEditor } from "./BudgetSettingsEditor"; import { MonthlyTrendsSection } from "./MonthlyTrendsSection"; import { SummaryBar } from "./widgets/SummaryBar"; @@ -26,6 +27,7 @@ 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; @@ -40,6 +42,7 @@ export function ActiveDashboard({ period, user, readOnly = false }: ActiveDashbo const { data, loading, trendMonths, setTrendMonths, refresh } = useDashboardData(currentPeriod.year, currentPeriod.month); + const expenseFrecencyData = useExpenseFrecencyData({ pageSize: 10 }); function handlePeriodUpdated(updatedPeriod: BudgetPeriod) { setCurrentPeriod(updatedPeriod); @@ -171,6 +174,11 @@ export function ActiveDashboard({ period, user, readOnly = false }: ActiveDashbo )} + {/* Repeated Expenses Chart */} + + + + {/* Cumulative Spend Chart */} {data.cumulativeData.length > 0 && ( diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx new file mode 100644 index 0000000..c1b2b77 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -0,0 +1,138 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { formatCurrency } from "@gofin/core"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@gofin/ui/components/card"; +import type { ExpenseSuggestion } from "../../../expense-autocomplete/types"; +import type { ExpenseFrecencyDataState } from "../../hooks/useExpenseFrecencyData"; + +interface ExpenseFrecencyChartProps extends ExpenseFrecencyDataState {} + +interface ChartDatum { + name: string; + frequency: number; + recencyBucket: ExpenseSuggestion["recencyBucket"]; + lastUsedAt: string; + amount: number; + currency: string; + expenseType: string; +} + +const RECENCY_LABELS: Record = { + today: "Today", + last_7_days: "Last 7 days", + last_30_days: "Last 30 days", + older: "Older", +}; + +const RECENCY_COLORS: Record = { + today: "var(--primary)", + last_7_days: "var(--chart-2)", + last_30_days: "var(--chart-3)", + older: "var(--muted-foreground)", +}; + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +interface ExpenseFrecencyTooltipProps { + active?: boolean; + payload?: Array<{ payload: ChartDatum }>; +} + +function ExpenseFrecencyTooltip({ active, payload }: ExpenseFrecencyTooltipProps) { + if (!active || !payload?.length) return null; + + const datum = payload[0].payload; + + return ( +
+

{datum.name}

+

Frequency: {datum.frequency}

+

Recency: {RECENCY_LABELS[datum.recencyBucket]}

+

Last used: {formatDate(datum.lastUsedAt)}

+

Latest amount: {formatCurrency(datum.amount, datum.currency)}

+

Type: {datum.expenseType}

+
+ ); +} + +export function ExpenseFrecencyChart({ + status, + suggestions, + errorMessage, +}: ExpenseFrecencyChartProps) { + const chartData: ChartDatum[] = suggestions.map((suggestion) => ({ + name: suggestion.name, + frequency: suggestion.frequency, + recencyBucket: suggestion.recencyBucket, + lastUsedAt: suggestion.lastUsedAt, + amount: suggestion.amount, + currency: suggestion.currency, + expenseType: suggestion.expenseType, + })); + + return ( + + + Repeated Expenses + + + {status === "loading" && ( +

Loading repeated expenses...

+ )} + {status === "error" && ( +

+ {errorMessage ?? "Repeated expenses are unavailable right now."} +

+ )} + {status === "empty" && ( +

+ Not enough expense history yet to show repeated expenses. +

+ )} + {status === "success" && ( + <> +

+ Frequency shows how often you have logged each expense. Color shows recency. +

+ + + + + + } /> + + {chartData.map((datum) => ( + + ))} + + + + + )} +
+
+ ); +} diff --git a/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts new file mode 100644 index 0000000..64f174d --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { expenseSuggestionsApi } from "../../expense-autocomplete/api"; +import type { ExpenseSuggestion } from "../../expense-autocomplete/types"; + +export interface ExpenseFrecencyDataState { + status: "loading" | "success" | "empty" | "error"; + suggestions: ExpenseSuggestion[]; + errorMessage: string | null; +} + +export interface UseExpenseFrecencyDataOptions { + pageSize?: number; +} + +const DEFAULT_PAGE_SIZE = 10; +const ERROR_MESSAGE = "Repeated expenses are unavailable right now."; + +export function useExpenseFrecencyData( + options: UseExpenseFrecencyDataOptions = {}, +): ExpenseFrecencyDataState { + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const [state, setState] = useState({ + status: "loading", + suggestions: [], + errorMessage: null, + }); + + useEffect(() => { + const controller = new AbortController(); + setState({ status: "loading", suggestions: [], errorMessage: null }); + + async function fetchSuggestions() { + try { + const response = await expenseSuggestionsApi.getSuggestions( + 1, + pageSize, + controller.signal, + ); + + if (controller.signal.aborted) return; + + const suggestions = response.data.slice(0, pageSize); + setState({ + status: suggestions.length > 0 ? "success" : "empty", + suggestions, + errorMessage: null, + }); + } catch (error) { + if (controller.signal.aborted) return; + setState({ + status: "error", + suggestions: [], + errorMessage: error instanceof Error ? error.message : ERROR_MESSAGE, + }); + } + } + + void fetchSuggestions(); + + return () => controller.abort(); + }, [pageSize]); + + return state; +} From 37317daaf5ade0a95fc094cbdc8b7f62d6a31441 Mon Sep 17 00:00:00 2001 From: thompson Date: Fri, 5 Jun 2026 23:22:09 +0100 Subject: [PATCH 2/4] fix(finance): harden repeated expenses dashboard tests --- .../__tests__/DashboardFeature.test.tsx | 89 ++++++++++--------- .../widgets/ExpenseFrecencyChart.tsx | 23 ++++- 2 files changed, 68 insertions(+), 44 deletions(-) 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 df14d4c..8182f1e 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -202,12 +202,12 @@ function renderDashboard(user = testUser) { describe("DashboardFeature", () => { beforeEach(() => { - // Reset global.fetch before each test; each test sets its own createMockApi + // Reset globalThis.fetch before each test; each test sets its own createMockApi }); it("renders skeleton loading state initially", () => { // Mock fetch that never resolves (simulates loading) - global.fetch = (() => new Promise(() => {})) as unknown as typeof fetch; + globalThis.fetch = (() => new Promise(() => {})) as unknown as typeof fetch; renderDashboard(); const skeletons = document.querySelectorAll('[data-slot="skeleton"]'); @@ -216,7 +216,7 @@ describe("DashboardFeature", () => { describe("active period exists", () => { it("renders summary bar with budget values", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -237,7 +237,7 @@ describe("DashboardFeature", () => { }); it("renders empty state with CTA to log expense", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -259,16 +259,18 @@ describe("DashboardFeature", () => { "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }); - global.fetch = mockApi as unknown as typeof fetch; + globalThis.fetch = mockApi as unknown as typeof fetch; renderDashboard(); await waitFor(() => { expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); }); - expect(screen.getByText("Groceries")).toBeInTheDocument(); - expect(screen.getByText("Coffee")).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.getByText("Frequency: 114")).toBeInTheDocument(); + expect(screen.getByText("Recency: Last 7 days")).toBeInTheDocument(); expect( mockApi._calls.some((call) => call.url.includes("/api/expenses/suggestions?page=1&pageSize=10"), @@ -277,7 +279,7 @@ describe("DashboardFeature", () => { }); it("shows a local repeated-expenses empty state without changing dashboard empty expense behavior", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -294,7 +296,7 @@ describe("DashboardFeature", () => { }); it("keeps other dashboard sections rendering when repeated-expenses fetch fails", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), "/api/expenses/suggestions": { @@ -309,11 +311,14 @@ describe("DashboardFeature", () => { }); expect(screen.getByText("Repeated Expenses")).toBeInTheDocument(); - expect(screen.getByText("Suggestions failed")).toBeInTheDocument(); + expect( + screen.getByText("Repeated expenses are unavailable right now."), + ).toBeInTheDocument(); + expect(screen.queryByText("Suggestions failed")).not.toBeInTheDocument(); }); it("displays currency symbol from user profile", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -330,7 +335,7 @@ describe("DashboardFeature", () => { }); it("color-codes remaining balance green when > 30%", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -349,7 +354,7 @@ describe("DashboardFeature", () => { it("shows no color class when budget is $0", async () => { const zeroBudgetPeriod = buildPeriod({ ...testPeriod, budgetAmount: 0 }); - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: zeroBudgetPeriod } }, "/api/finance/summary": { body: { @@ -391,7 +396,7 @@ describe("DashboardFeature", () => { describe("no period exists (PERIOD_NOT_FOUND)", () => { it("shows creation prompt with default values", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 404, body: { code: "PERIOD_NOT_FOUND", message: "No budget period found for 2026-05" }, @@ -418,7 +423,7 @@ describe("DashboardFeature", () => { }); it("shows zero-budget warning when default budget is $0", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 404, body: { code: "PERIOD_NOT_FOUND", message: "No budget period found for 2026-05" }, @@ -435,7 +440,7 @@ describe("DashboardFeature", () => { }); it("uses fallback defaults when defaults endpoint fails", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 404, body: { code: "PERIOD_NOT_FOUND", message: "No budget period found for 2026-05" }, @@ -456,7 +461,7 @@ describe("DashboardFeature", () => { }); it("renders CreatePeriodPrompt with null defaults when defaults fetch returns server error", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 404, body: { code: "PERIOD_NOT_FOUND", message: "No budget period found for 2026-05" }, @@ -490,7 +495,7 @@ describe("DashboardFeature", () => { }); it("validates E/D/S split sums to 100%", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 404, body: { code: "PERIOD_NOT_FOUND", message: "No budget period found for 2026-05" }, @@ -555,7 +560,7 @@ describe("DashboardFeature", () => { "/api/finance/prorata/upcoming": { body: { schedules: [] } }, "/api/finance/spending/trends": { body: { trends: [] } }, }); - global.fetch = mockApi as unknown as typeof fetch; + globalThis.fetch = mockApi as unknown as typeof fetch; const user = userEvent.setup(); renderDashboard(); @@ -604,7 +609,7 @@ describe("DashboardFeature", () => { "/api/finance/prorata/upcoming": { body: { schedules: [] } }, "/api/finance/spending/trends": { body: { trends: [] } }, }); - global.fetch = mockApi as unknown as typeof fetch; + globalThis.fetch = mockApi as unknown as typeof fetch; const user = userEvent.setup(); renderDashboard(); @@ -646,7 +651,7 @@ describe("DashboardFeature", () => { describe("error state", () => { it("renders error state on server error", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: "Database connection failed" }, @@ -666,7 +671,7 @@ describe("DashboardFeature", () => { it("retries fetch on retry button click", async () => { // First call returns error, subsequent calls return success. // Use mockSequence-like behavior: swap fetch after error is shown. - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: "Temporary failure" }, @@ -679,7 +684,7 @@ describe("DashboardFeature", () => { }); // Now swap to a successful mock for retry - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -695,7 +700,7 @@ describe("DashboardFeature", () => { describe("recent expenses", () => { it("shows recent expenses when expenses exist", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -705,14 +710,14 @@ describe("DashboardFeature", () => { expect(screen.getByText("Recent Expenses")).toBeInTheDocument(); }); - expect(screen.getByText("Groceries")).toBeInTheDocument(); - expect(screen.getByText("Coffee")).toBeInTheDocument(); + expect(screen.getAllByText("Groceries").length).toBeGreaterThan(0); + expect(screen.getAllByText("Coffee").length).toBeGreaterThan(0); expect(screen.getByText("$500.00")).toBeInTheDocument(); expect(screen.getByText("$45.00")).toBeInTheDocument(); }); it("shows View All link to /expenses", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -727,7 +732,7 @@ describe("DashboardFeature", () => { }); it("updates total spent in summary bar from summary endpoint", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -749,7 +754,7 @@ describe("DashboardFeature", () => { describe("category gauges", () => { it("renders three category gauges with allocated and spent amounts", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -777,7 +782,7 @@ describe("DashboardFeature", () => { }); it("shows over-budget indicator when category exceeds allocation", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, "/api/finance/summary": { body: { @@ -809,7 +814,7 @@ describe("DashboardFeature", () => { }); it("shows over-budget indicator for zero-allocation category with spending", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, "/api/finance/summary": { body: { @@ -842,7 +847,7 @@ describe("DashboardFeature", () => { describe("pacing indicator", () => { it("renders pacing data from summary endpoint", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -858,7 +863,7 @@ describe("DashboardFeature", () => { }); it("shows on-track indicator when spending is under pace", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, "/api/finance/summary": { body: { @@ -888,7 +893,7 @@ describe("DashboardFeature", () => { }); it("shows over-budget amount when totalSpent exceeds budget", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, "/api/finance/summary": { body: { @@ -928,7 +933,7 @@ describe("DashboardFeature", () => { }); // Override: after period found, remaining URLs will hit "No mock route" error // which simulates network failures - global.fetch = mockApi as unknown as typeof fetch; + globalThis.fetch = mockApi as unknown as typeof fetch; renderDashboard(); // Dashboard header should still render even with data errors @@ -940,7 +945,7 @@ describe("DashboardFeature", () => { describe("responsive layout", () => { it("shows Log Expense button on mobile viewport", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataEmptyRoutes(), }) as unknown as typeof fetch; @@ -955,7 +960,7 @@ describe("DashboardFeature", () => { }); it("charts container has hidden md:block class for mobile hiding", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -972,7 +977,7 @@ describe("DashboardFeature", () => { describe("budget settings editor", () => { it("shows budget settings editor when gear button is clicked", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -993,7 +998,7 @@ describe("DashboardFeature", () => { }); it("hides editor when cancel is clicked", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -1011,7 +1016,7 @@ describe("DashboardFeature", () => { }); it("validates E/D/S split sums to 100% on save", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -1037,7 +1042,7 @@ describe("DashboardFeature", () => { describe("historical comparison widget", () => { it("shows historical comparison data with change indicator", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, ...dashboardDataWithExpensesRoutes(), }) as unknown as typeof fetch; @@ -1055,7 +1060,7 @@ describe("DashboardFeature", () => { }); it("shows 'not enough data' when only one period exists", async () => { - global.fetch = createMockApi({ + globalThis.fetch = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, "/api/finance/summary": { body: { summary: testSummary } }, "/api/finance/spending/by-tag": { body: { tagSpending: testTagSpending } }, diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx index c1b2b77..8015f3d 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -77,7 +77,6 @@ function ExpenseFrecencyTooltip({ active, payload }: ExpenseFrecencyTooltipProps export function ExpenseFrecencyChart({ status, suggestions, - errorMessage, }: ExpenseFrecencyChartProps) { const chartData: ChartDatum[] = suggestions.map((suggestion) => ({ name: suggestion.name, @@ -100,7 +99,7 @@ export function ExpenseFrecencyChart({ )} {status === "error" && (

- {errorMessage ?? "Repeated expenses are unavailable right now."} + Repeated expenses are unavailable right now.

)} {status === "empty" && ( @@ -113,6 +112,17 @@ export function ExpenseFrecencyChart({

Frequency shows how often you have logged each expense. Color shows recency.

+
+ {Object.entries(RECENCY_LABELS).map(([bucket, label]) => ( + + + {label} + + ))} +
+
    + {chartData.map((datum) => ( +
  • + {datum.name} + Frequency: {datum.frequency} + Recency: {RECENCY_LABELS[datum.recencyBucket]} +
  • + ))} +
)} From ecf6aab7ca7fb6b8dcf4ae314f9f9fad5b83ab56 Mon Sep 17 00:00:00 2001 From: thompson Date: Fri, 5 Jun 2026 23:25:51 +0100 Subject: [PATCH 3/4] refactor(finance): split repeated expenses tooltip --- .../widgets/ExpenseFrecencyChart.tsx | 87 +++++-------------- .../widgets/ExpenseFrecencyTooltip.tsx | 36 ++++++++ .../widgets/expenseFrecencyChartData.ts | 25 ++++++ 3 files changed, 85 insertions(+), 63 deletions(-) create mode 100644 frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyTooltip.tsx create mode 100644 frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx index 8015f3d..bf5db40 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -8,7 +8,6 @@ import { XAxis, YAxis, } from "recharts"; -import { formatCurrency } from "@gofin/core"; import { Card, CardContent, @@ -17,68 +16,18 @@ import { } from "@gofin/ui/components/card"; import type { ExpenseSuggestion } from "../../../expense-autocomplete/types"; import type { ExpenseFrecencyDataState } from "../../hooks/useExpenseFrecencyData"; - -interface ExpenseFrecencyChartProps extends ExpenseFrecencyDataState {} - -interface ChartDatum { - name: string; - frequency: number; - recencyBucket: ExpenseSuggestion["recencyBucket"]; - lastUsedAt: string; - amount: number; - currency: string; - expenseType: string; -} - -const RECENCY_LABELS: Record = { - today: "Today", - last_7_days: "Last 7 days", - last_30_days: "Last 30 days", - older: "Older", -}; - -const RECENCY_COLORS: Record = { - today: "var(--primary)", - last_7_days: "var(--chart-2)", - last_30_days: "var(--chart-3)", - older: "var(--muted-foreground)", -}; - -function formatDate(value: string): string { - return new Date(value).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -interface ExpenseFrecencyTooltipProps { - active?: boolean; - payload?: Array<{ payload: ChartDatum }>; -} - -function ExpenseFrecencyTooltip({ active, payload }: ExpenseFrecencyTooltipProps) { - if (!active || !payload?.length) return null; - - const datum = payload[0].payload; - - return ( -
-

{datum.name}

-

Frequency: {datum.frequency}

-

Recency: {RECENCY_LABELS[datum.recencyBucket]}

-

Last used: {formatDate(datum.lastUsedAt)}

-

Latest amount: {formatCurrency(datum.amount, datum.currency)}

-

Type: {datum.expenseType}

-
- ); -} +import { ExpenseFrecencyTooltip } from "./ExpenseFrecencyTooltip"; +import { + RECENCY_COLORS, + RECENCY_LABELS, +} from "./expenseFrecencyChartData"; +import type { ExpenseFrecencyChartDatum } from "./expenseFrecencyChartData"; export function ExpenseFrecencyChart({ status, suggestions, -}: ExpenseFrecencyChartProps) { - const chartData: ChartDatum[] = suggestions.map((suggestion) => ({ +}: ExpenseFrecencyDataState) { + const chartData: ExpenseFrecencyChartDatum[] = suggestions.map((suggestion) => ({ name: suggestion.name, frequency: suggestion.frequency, recencyBucket: suggestion.recencyBucket, @@ -112,12 +61,18 @@ export function ExpenseFrecencyChart({

Frequency shows how often you have logged each expense. Color shows recency.

-
+
{Object.entries(RECENCY_LABELS).map(([bucket, label]) => ( {label} @@ -140,9 +95,15 @@ export function ExpenseFrecencyChart({ -
    +
      {chartData.map((datum) => ( -
    • +
    • {datum.name} Frequency: {datum.frequency} Recency: {RECENCY_LABELS[datum.recencyBucket]} diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyTooltip.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyTooltip.tsx new file mode 100644 index 0000000..d107603 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyTooltip.tsx @@ -0,0 +1,36 @@ +import { formatCurrency } from "@gofin/core"; +import { RECENCY_LABELS } from "./expenseFrecencyChartData"; +import type { ExpenseFrecencyChartDatum } from "./expenseFrecencyChartData"; + +export interface ExpenseFrecencyTooltipProps { + active?: boolean; + payload?: Array<{ payload: ExpenseFrecencyChartDatum }>; +} + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function ExpenseFrecencyTooltip({ + active, + payload, +}: ExpenseFrecencyTooltipProps) { + if (!active || !payload?.length) return null; + + const datum = payload[0].payload; + + return ( +
      +

      {datum.name}

      +

      Frequency: {datum.frequency}

      +

      Recency: {RECENCY_LABELS[datum.recencyBucket]}

      +

      Last used: {formatDate(datum.lastUsedAt)}

      +

      Latest amount: {formatCurrency(datum.amount, datum.currency)}

      +

      Type: {datum.expenseType}

      +
      + ); +} diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts new file mode 100644 index 0000000..ee1dfb1 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts @@ -0,0 +1,25 @@ +import type { ExpenseSuggestion } from "../../../expense-autocomplete/types"; + +export interface ExpenseFrecencyChartDatum { + name: string; + frequency: number; + recencyBucket: ExpenseSuggestion["recencyBucket"]; + lastUsedAt: string; + amount: number; + currency: string; + expenseType: string; +} + +export const RECENCY_LABELS: Record = { + today: "Today", + last_7_days: "Last 7 days", + last_30_days: "Last 30 days", + older: "Older", +}; + +export const RECENCY_COLORS: Record = { + today: "var(--primary)", + last_7_days: "var(--chart-2)", + last_30_days: "var(--chart-3)", + older: "var(--muted-foreground)", +}; From 0d6e4aa6161a8eb86cbae316870e0972575f00fb Mon Sep 17 00:00:00 2001 From: thompson Date: Fri, 5 Jun 2026 23:43:07 +0100 Subject: [PATCH 4/4] test(e2e): scope dashboard expense assertions --- e2e/tests/admin-assumption.spec.ts | 14 ++++++++++---- e2e/tests/mobile-expense.spec.ts | 7 +++++-- e2e/tests/registration-onboarding.spec.ts | 7 +++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/e2e/tests/admin-assumption.spec.ts b/e2e/tests/admin-assumption.spec.ts index 7b2b788..49c18ca 100644 --- a/e2e/tests/admin-assumption.spec.ts +++ b/e2e/tests/admin-assumption.spec.ts @@ -22,8 +22,11 @@ test.describe("Admin Identity Assumption", () => { }); // Verify the expense is visible on the regular user's dashboard - await expect(page.getByText("User Expense")).toBeVisible(); - await expect(page.getByText("$75.00").first()).toBeVisible(); + const regularUserRecentExpenses = page + .getByText("Recent Expenses") + .locator('xpath=ancestor::*[@data-slot="card"][1]'); + await expect(regularUserRecentExpenses.getByText("User Expense")).toBeVisible(); + await expect(regularUserRecentExpenses.getByText("$75.00")).toBeVisible(); // Log out the regular user await page.getByRole("button", { name: "Logout" }).click(); @@ -52,8 +55,11 @@ test.describe("Admin Identity Assumption", () => { // Step 5: Verify we're now viewing the regular user's dashboard await expect(page).toHaveURL(/\/dashboard/); - await expect(page.getByText("User Expense")).toBeVisible(); - await expect(page.getByText("$75.00").first()).toBeVisible(); + const assumedUserRecentExpenses = page + .getByText("Recent Expenses") + .locator('xpath=ancestor::*[@data-slot="card"][1]'); + await expect(assumedUserRecentExpenses.getByText("User Expense")).toBeVisible(); + await expect(assumedUserRecentExpenses.getByText("$75.00")).toBeVisible(); // The "Return to Admin" floating button should be visible const returnButton = page.getByRole("button", { diff --git a/e2e/tests/mobile-expense.spec.ts b/e2e/tests/mobile-expense.spec.ts index 0beb46b..c8f0e12 100644 --- a/e2e/tests/mobile-expense.spec.ts +++ b/e2e/tests/mobile-expense.spec.ts @@ -39,8 +39,11 @@ test.describe("Mobile Expense Logging", () => { await page.waitForURL("**/dashboard"); // Step 3: Verify the expense appears on the dashboard - await expect(page.getByText("Mobile Coffee")).toBeVisible(); - await expect(page.getByText("$5.50").first()).toBeVisible(); + const recentExpenses = page + .getByText("Recent Expenses") + .locator('xpath=ancestor::*[@data-slot="card"][1]'); + await expect(recentExpenses.getByText("Mobile Coffee")).toBeVisible(); + await expect(recentExpenses.getByText("$5.50")).toBeVisible(); // Step 4: Navigate to expense log via mobile menu await page.getByRole("button", { name: "Open menu" }).click(); diff --git a/e2e/tests/registration-onboarding.spec.ts b/e2e/tests/registration-onboarding.spec.ts index 4fcb338..6de69c7 100644 --- a/e2e/tests/registration-onboarding.spec.ts +++ b/e2e/tests/registration-onboarding.spec.ts @@ -35,8 +35,11 @@ test.describe("Registration → Onboarding → First Expense", () => { // Step 5: Verify the dashboard reflects the new expense // Recent expenses section should show the expense - await expect(page.getByText("Grocery Shopping")).toBeVisible(); - await expect(page.getByText("$42.50").first()).toBeVisible(); + const recentExpenses = page + .getByText("Recent Expenses") + .locator('xpath=ancestor::*[@data-slot="card"][1]'); + await expect(recentExpenses.getByText("Grocery Shopping")).toBeVisible(); + await expect(recentExpenses.getByText("$42.50")).toBeVisible(); // Total Spent should update await expect(page.getByText("Total Spent")).toBeVisible();