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(); 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..8182f1e 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 }, }, @@ -171,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"]'); @@ -185,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; @@ -206,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; @@ -223,8 +254,71 @@ 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(), + }); + globalThis.fetch = mockApi as unknown as typeof fetch; + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Repeated Expenses")).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"), + ), + ).toBe(true); + }); + + it("shows a local repeated-expenses empty state without changing dashboard empty expense behavior", async () => { + globalThis.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 () => { + globalThis.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("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; @@ -241,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; @@ -260,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: { @@ -302,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" }, @@ -329,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" }, @@ -346,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" }, @@ -367,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" }, @@ -401,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" }, @@ -466,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(); @@ -515,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(); @@ -557,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" }, @@ -577,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" }, @@ -590,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; @@ -606,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; @@ -616,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; @@ -638,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; @@ -660,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; @@ -688,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: { @@ -720,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: { @@ -753,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; @@ -769,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: { @@ -799,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: { @@ -839,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 @@ -851,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; @@ -866,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; @@ -883,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; @@ -904,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; @@ -922,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; @@ -948,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; @@ -966,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/__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..bf5db40 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -0,0 +1,118 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@gofin/ui/components/card"; +import type { ExpenseSuggestion } from "../../../expense-autocomplete/types"; +import type { ExpenseFrecencyDataState } from "../../hooks/useExpenseFrecencyData"; +import { ExpenseFrecencyTooltip } from "./ExpenseFrecencyTooltip"; +import { + RECENCY_COLORS, + RECENCY_LABELS, +} from "./expenseFrecencyChartData"; +import type { ExpenseFrecencyChartDatum } from "./expenseFrecencyChartData"; + +export function ExpenseFrecencyChart({ + status, + suggestions, +}: ExpenseFrecencyDataState) { + const chartData: ExpenseFrecencyChartDatum[] = 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" && ( +

+ 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. +

+
+ {Object.entries(RECENCY_LABELS).map(([bucket, label]) => ( + + + {label} + + ))} +
+ + + + + + } /> + + {chartData.map((datum) => ( + + ))} + + + +
    + {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)", +}; 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; +}