From 6749c3c098748b1fdb4f9dcf931f706c95d2526c Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 6 Jun 2026 01:06:18 +0100 Subject: [PATCH 1/4] refactor(finance): simplify repeated expenses chart --- .../__tests__/DashboardFeature.test.tsx | 6 ++- .../__tests__/useExpenseFrecencyData.test.ts | 38 +++++++++++++++- .../widgets/ExpenseFrecencyChart.tsx | 43 +++++++------------ .../widgets/expenseFrecencyChartData.ts | 25 +++++++---- .../dashboard/hooks/useExpenseFrecencyData.ts | 4 +- 5 files changed, 76 insertions(+), 40 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 8182f1e..a7eff4a 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -269,8 +269,10 @@ describe("DashboardFeature", () => { 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(screen.getByLabelText("Recency legend")).toHaveTextContent("Today"); + expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 7 days"); + expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 30 days"); + expect(screen.getByLabelText("Recency legend")).not.toHaveTextContent("Older"); expect( mockApi._calls.some((call) => call.url.includes("/api/expenses/suggestions?page=1&pageSize=10"), diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts index 7a7dac8..fb714c6 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts +++ b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts @@ -28,6 +28,18 @@ const suggestions = [ }, ]; +const olderSuggestion = { + name: "Old Bus Fare", + amount: 350, + currency: "USD", + expenseType: "essentials" as const, + tagId: "tag-transit", + frequency: 20, + lastUsedAt: "2026-01-01T09:00:00Z", + recencyBucket: "older" as const, + frecencyScore: 20, +}; + describe("useExpenseFrecencyData", () => { it("fetches page 1 suggestions and exposes success state", async () => { const mockApi = createMockApi({ @@ -49,10 +61,32 @@ describe("useExpenseFrecencyData", () => { expect(mockApi._calls[0].url).toContain("/api/expenses/suggestions?page=1&pageSize=2"); }); - it("exposes empty state when no suggestions are returned", async () => { + it("ignores older suggestions", async () => { + globalThis.fetch = createMockApi({ + "/api/expenses/suggestions": { + body: { + data: [olderSuggestion, ...suggestions], + total: 3, + page: 1, + pageSize: 10, + hasMore: false, + }, + }, + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useExpenseFrecencyData()); + + await waitFor(() => { + expect(result.current.status).toBe("success"); + }); + + expect(result.current.suggestions).toEqual(suggestions); + }); + + it("exposes empty state when no current suggestions are returned", async () => { globalThis.fetch = createMockApi({ "/api/expenses/suggestions": { - body: { data: [], total: 0, page: 1, pageSize: 10, hasMore: false }, + body: { data: [olderSuggestion], total: 1, page: 1, pageSize: 10, hasMore: false }, }, }) as unknown as typeof fetch; 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 bf5db40..915fdb6 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -14,12 +14,12 @@ import { 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, + isActiveRecencyBucket, } from "./expenseFrecencyChartData"; import type { ExpenseFrecencyChartDatum } from "./expenseFrecencyChartData"; @@ -27,15 +27,19 @@ 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, - })); + const chartData: ExpenseFrecencyChartDatum[] = suggestions.flatMap((suggestion) => { + if (!isActiveRecencyBucket(suggestion.recencyBucket)) return []; + + return [{ + name: suggestion.name, + frequency: suggestion.frequency, + recencyBucket: suggestion.recencyBucket, + lastUsedAt: suggestion.lastUsedAt, + amount: suggestion.amount, + currency: suggestion.currency, + expenseType: suggestion.expenseType, + }]; + }); return ( @@ -70,8 +74,7 @@ export function ExpenseFrecencyChart({ {label} @@ -95,21 +98,7 @@ 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/expenseFrecencyChartData.ts b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts index ee1dfb1..b5a2519 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts @@ -1,25 +1,34 @@ import type { ExpenseSuggestion } from "../../../expense-autocomplete/types"; +export type ActiveRecencyBucket = Exclude< + ExpenseSuggestion["recencyBucket"], + "older" +>; + export interface ExpenseFrecencyChartDatum { name: string; frequency: number; - recencyBucket: ExpenseSuggestion["recencyBucket"]; + recencyBucket: ActiveRecencyBucket; lastUsedAt: string; amount: number; currency: string; expenseType: string; } -export const RECENCY_LABELS: Record = { +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)", +export const RECENCY_COLORS: Record = { + today: "#16a34a", + last_7_days: "#2563eb", + last_30_days: "#f59e0b", }; + +export function isActiveRecencyBucket( + bucket: ExpenseSuggestion["recencyBucket"], +): bucket is ActiveRecencyBucket { + return bucket !== "older"; +} diff --git a/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts index 64f174d..0effdf6 100644 --- a/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts +++ b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts @@ -39,7 +39,9 @@ export function useExpenseFrecencyData( if (controller.signal.aborted) return; - const suggestions = response.data.slice(0, pageSize); + const suggestions = response.data + .filter((suggestion) => suggestion.recencyBucket !== "older") + .slice(0, pageSize); setState({ status: suggestions.length > 0 ? "success" : "empty", suggestions, From 5428126bd328c4be0e3504adcd86925bd44dabca Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 6 Jun 2026 01:09:46 +0100 Subject: [PATCH 2/4] refactor(finance): simplify repeated expenses chart --- .../components/widgets/expenseFrecencyChartData.ts | 6 +++--- frontend/packages/ui/src/styles/globals.css | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts index b5a2519..e5b92a6 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts @@ -22,9 +22,9 @@ export const RECENCY_LABELS: Record = { }; export const RECENCY_COLORS: Record = { - today: "#16a34a", - last_7_days: "#2563eb", - last_30_days: "#f59e0b", + today: "var(--recency-today)", + last_7_days: "var(--recency-last-7-days)", + last_30_days: "var(--recency-last-30-days)", }; export function isActiveRecencyBucket( diff --git a/frontend/packages/ui/src/styles/globals.css b/frontend/packages/ui/src/styles/globals.css index b12c2ab..005a03c 100644 --- a/frontend/packages/ui/src/styles/globals.css +++ b/frontend/packages/ui/src/styles/globals.css @@ -43,6 +43,9 @@ --color-essentials: var(--essentials); --color-desires: var(--desires); --color-savings: var(--savings); + --color-recency-today: var(--recency-today); + --color-recency-last-7-days: var(--recency-last-7-days); + --color-recency-last-30-days: var(--recency-last-30-days); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -79,6 +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); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -94,6 +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); --background: var(--brand-background); --foreground: var(--brand-foreground); --card: var(--brand-primary); From 33b463e5efd20fda0eb95b4041aa98948043f294 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 6 Jun 2026 01:17:45 +0100 Subject: [PATCH 3/4] mend --- .../widgets/ExpenseFrecencyChart.tsx | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) 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 915fdb6..cc06c1e 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -27,19 +27,23 @@ export function ExpenseFrecencyChart({ status, suggestions, }: ExpenseFrecencyDataState) { - const chartData: ExpenseFrecencyChartDatum[] = suggestions.flatMap((suggestion) => { - if (!isActiveRecencyBucket(suggestion.recencyBucket)) return []; + const chartData: ExpenseFrecencyChartDatum[] = suggestions.flatMap( + (suggestion) => { + if (!isActiveRecencyBucket(suggestion.recencyBucket)) return []; - return [{ - name: suggestion.name, - frequency: suggestion.frequency, - recencyBucket: suggestion.recencyBucket, - lastUsedAt: suggestion.lastUsedAt, - amount: suggestion.amount, - currency: suggestion.currency, - expenseType: suggestion.expenseType, - }]; - }); + return [ + { + name: suggestion.name, + frequency: suggestion.frequency, + recencyBucket: suggestion.recencyBucket, + lastUsedAt: suggestion.lastUsedAt, + amount: suggestion.amount, + currency: suggestion.currency, + expenseType: suggestion.expenseType, + }, + ]; + }, + ); return ( @@ -48,7 +52,9 @@ export function ExpenseFrecencyChart({ {status === "loading" && ( -

Loading repeated expenses...

+

+ Loading repeated expenses... +

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

@@ -63,7 +69,8 @@ export function ExpenseFrecencyChart({ {status === "success" && ( <>

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

{label} ))}
- + - - + + } /> {chartData.map((datum) => ( - + ))} - )}
From 2f220c6471a4986621a3e30ed76ed038db14b461 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 6 Jun 2026 01:23:37 +0100 Subject: [PATCH 4/4] fix(finance): preserve frecency chart accessibility --- .../__tests__/DashboardFeature.test.tsx | 3 ++ .../__tests__/useExpenseFrecencyData.test.ts | 40 ++++++++++++++++- .../widgets/ExpenseFrecencyChart.tsx | 44 +++++++++---------- .../widgets/expenseFrecencyChartData.ts | 18 ++++++-- .../dashboard/hooks/useExpenseFrecencyData.ts | 36 ++++++++++----- 5 files changed, 101 insertions(+), 40 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 a7eff4a..b4807fb 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -273,6 +273,9 @@ describe("DashboardFeature", () => { expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 7 days"); expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 30 days"); expect(screen.getByLabelText("Recency legend")).not.toHaveTextContent("Older"); + expect(screen.getByLabelText("Repeated expense details")).toHaveTextContent( + "Groceries: Frequency 114, Recency Last 7 days", + ); expect( mockApi._calls.some((call) => call.url.includes("/api/expenses/suggestions?page=1&pageSize=10"), diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts index fb714c6..63e90d3 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts +++ b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; -import { createMockApi } from "@gofin/test-utils"; +import { createMockApi, mockSequence } from "@gofin/test-utils"; import { useExpenseFrecencyData } from "../hooks/useExpenseFrecencyData"; const suggestions = [ @@ -61,6 +61,44 @@ describe("useExpenseFrecencyData", () => { expect(mockApi._calls[0].url).toContain("/api/expenses/suggestions?page=1&pageSize=2"); }); + it("fetches later pages when older suggestions underfill the chart", async () => { + const mockApi = createMockApi({ + "/api/expenses/suggestions": mockSequence([ + { + body: { + data: [olderSuggestion], + total: 3, + page: 1, + pageSize: 2, + hasMore: true, + }, + }, + { + body: { + data: suggestions, + total: 3, + page: 2, + pageSize: 2, + hasMore: false, + }, + }, + ]), + }); + globalThis.fetch = mockApi as unknown as typeof fetch; + + const { result } = renderHook(() => useExpenseFrecencyData({ pageSize: 2 })); + + await waitFor(() => { + expect(result.current.status).toBe("success"); + }); + + expect(result.current.suggestions).toEqual(suggestions); + expect(mockApi._calls.map((call) => call.url)).toEqual([ + "/api/expenses/suggestions?page=1&pageSize=2", + "/api/expenses/suggestions?page=2&pageSize=2", + ]); + }); + it("ignores older suggestions", async () => { globalThis.fetch = createMockApi({ "/api/expenses/suggestions": { 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 cc06c1e..f011ca2 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -17,9 +17,9 @@ import { import type { ExpenseFrecencyDataState } from "../../hooks/useExpenseFrecencyData"; import { ExpenseFrecencyTooltip } from "./ExpenseFrecencyTooltip"; import { + ACTIVE_RECENCY_BUCKETS, RECENCY_COLORS, RECENCY_LABELS, - isActiveRecencyBucket, } from "./expenseFrecencyChartData"; import type { ExpenseFrecencyChartDatum } from "./expenseFrecencyChartData"; @@ -27,23 +27,15 @@ export function ExpenseFrecencyChart({ status, suggestions, }: ExpenseFrecencyDataState) { - const chartData: ExpenseFrecencyChartDatum[] = suggestions.flatMap( - (suggestion) => { - if (!isActiveRecencyBucket(suggestion.recencyBucket)) return []; - - return [ - { - name: suggestion.name, - frequency: suggestion.frequency, - recencyBucket: suggestion.recencyBucket, - lastUsedAt: suggestion.lastUsedAt, - amount: suggestion.amount, - currency: suggestion.currency, - expenseType: suggestion.expenseType, - }, - ]; - }, - ); + 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 ( @@ -76,16 +68,13 @@ export function ExpenseFrecencyChart({ className="mb-3 flex flex-wrap gap-3 text-xs text-muted-foreground" aria-label="Recency legend" > - {Object.entries(RECENCY_LABELS).map(([bucket, label]) => ( + {ACTIVE_RECENCY_BUCKETS.map((bucket) => ( - {label} + {RECENCY_LABELS[bucket]} ))} @@ -121,6 +110,13 @@ 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/expenseFrecencyChartData.ts b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts index e5b92a6..9db8f9c 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/expenseFrecencyChartData.ts @@ -5,6 +5,10 @@ export type ActiveRecencyBucket = Exclude< "older" >; +export type ActiveExpenseSuggestion = ExpenseSuggestion & { + recencyBucket: ActiveRecencyBucket; +}; + export interface ExpenseFrecencyChartDatum { name: string; frequency: number; @@ -15,6 +19,12 @@ export interface ExpenseFrecencyChartDatum { expenseType: string; } +export const ACTIVE_RECENCY_BUCKETS: ActiveRecencyBucket[] = [ + "today", + "last_7_days", + "last_30_days", +]; + export const RECENCY_LABELS: Record = { today: "Today", last_7_days: "Last 7 days", @@ -27,8 +37,8 @@ export const RECENCY_COLORS: Record = { last_30_days: "var(--recency-last-30-days)", }; -export function isActiveRecencyBucket( - bucket: ExpenseSuggestion["recencyBucket"], -): bucket is ActiveRecencyBucket { - return bucket !== "older"; +export function isActiveExpenseSuggestion( + suggestion: ExpenseSuggestion, +): suggestion is ActiveExpenseSuggestion { + return suggestion.recencyBucket !== "older"; } diff --git a/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts index 0effdf6..4393794 100644 --- a/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts +++ b/frontend/apps/finance/src/features/dashboard/hooks/useExpenseFrecencyData.ts @@ -1,10 +1,13 @@ import { useEffect, useState } from "react"; import { expenseSuggestionsApi } from "../../expense-autocomplete/api"; -import type { ExpenseSuggestion } from "../../expense-autocomplete/types"; +import { + isActiveExpenseSuggestion, + type ActiveExpenseSuggestion, +} from "../components/widgets/expenseFrecencyChartData"; export interface ExpenseFrecencyDataState { status: "loading" | "success" | "empty" | "error"; - suggestions: ExpenseSuggestion[]; + suggestions: ActiveExpenseSuggestion[]; errorMessage: string | null; } @@ -15,6 +18,25 @@ export interface UseExpenseFrecencyDataOptions { const DEFAULT_PAGE_SIZE = 10; const ERROR_MESSAGE = "Repeated expenses are unavailable right now."; +async function fetchActiveSuggestions( + pageSize: number, + signal: AbortSignal, +): Promise { + const suggestions: ActiveExpenseSuggestion[] = []; + let page = 1; + let hasMore = true; + + while (suggestions.length < pageSize && hasMore && !signal.aborted) { + const response = await expenseSuggestionsApi.getSuggestions(page, pageSize, signal); + suggestions.push(...response.data.filter(isActiveExpenseSuggestion)); + hasMore = response.hasMore; + page += 1; + } + + return suggestions.slice(0, pageSize); +} + + export function useExpenseFrecencyData( options: UseExpenseFrecencyDataOptions = {}, ): ExpenseFrecencyDataState { @@ -31,17 +53,9 @@ export function useExpenseFrecencyData( async function fetchSuggestions() { try { - const response = await expenseSuggestionsApi.getSuggestions( - 1, - pageSize, - controller.signal, - ); + const suggestions = await fetchActiveSuggestions(pageSize, controller.signal); if (controller.signal.aborted) return; - - const suggestions = response.data - .filter((suggestion) => suggestion.recencyBucket !== "older") - .slice(0, pageSize); setState({ status: suggestions.length > 0 ? "success" : "empty", suggestions,