Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,13 @@ 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(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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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({
Expand All @@ -49,10 +61,70 @@ 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("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": {
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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ 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 {
ACTIVE_RECENCY_BUCKETS,
RECENCY_COLORS,
RECENCY_LABELS,
} from "./expenseFrecencyChartData";
Expand All @@ -44,7 +44,9 @@ export function ExpenseFrecencyChart({
</CardHeader>
<CardContent>
{status === "loading" && (
<p className="text-sm text-muted-foreground">Loading repeated expenses...</p>
<p className="text-sm text-muted-foreground">
Loading repeated expenses...
</p>
)}
{status === "error" && (
<p className="text-sm text-muted-foreground">
Expand All @@ -59,54 +61,59 @@ export function ExpenseFrecencyChart({
{status === "success" && (
<>
<p className="mb-4 text-sm text-muted-foreground">
Frequency shows how often you have logged each expense. Color shows recency.
Frequency shows how often you have logged each expense. Color
shows recency.
</p>
<div
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) => (
<span key={bucket} className="inline-flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{
backgroundColor:
RECENCY_COLORS[bucket as ExpenseSuggestion["recencyBucket"]],
}}
style={{ backgroundColor: RECENCY_COLORS[bucket] }}
/>
{label}
{RECENCY_LABELS[bucket]}
</span>
))}
</div>
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 42)}>
<ResponsiveContainer
width="100%"
height={Math.max(240, chartData.length * 42)}
>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 0, right: 24, left: 10, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" dataKey="frequency" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 12 }} />
<XAxis
type="number"
dataKey="frequency"
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fontSize: 12 }}
/>
<Tooltip content={<ExpenseFrecencyTooltip />} />
<Bar dataKey="frequency" radius={[0, 4, 4, 0]}>
{chartData.map((datum) => (
<Cell key={datum.name} fill={RECENCY_COLORS[datum.recencyBucket]} />
<Cell
key={datum.name}
fill={RECENCY_COLORS[datum.recencyBucket]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<ul
className="mt-4 space-y-2 text-sm"
aria-label="Repeated expense details"
>
<ul className="sr-only" aria-label="Repeated expense details">
{chartData.map((datum) => (
<li
key={datum.name}
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground"
>
<span className="font-medium text-foreground">{datum.name}</span>
<span>Frequency: {datum.frequency}</span>
<span>Recency: {RECENCY_LABELS[datum.recencyBucket]}</span>
<li key={datum.name}>
{datum.name}: Frequency {datum.frequency}, Recency {RECENCY_LABELS[datum.recencyBucket]}
</li>
))}
</ul>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import type { ExpenseSuggestion } from "../../../expense-autocomplete/types";

export type ActiveRecencyBucket = Exclude<
ExpenseSuggestion["recencyBucket"],
"older"
>;

export type ActiveExpenseSuggestion = ExpenseSuggestion & {
recencyBucket: ActiveRecencyBucket;
};

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<ExpenseSuggestion["recencyBucket"], string> = {
export const ACTIVE_RECENCY_BUCKETS: ActiveRecencyBucket[] = [
"today",
"last_7_days",
"last_30_days",
];

export const RECENCY_LABELS: Record<ActiveRecencyBucket, string> = {
today: "Today",
last_7_days: "Last 7 days",
last_30_days: "Last 30 days",
older: "Older",
};

export const RECENCY_COLORS: Record<ExpenseSuggestion["recencyBucket"], string> = {
today: "var(--primary)",
last_7_days: "var(--chart-2)",
last_30_days: "var(--chart-3)",
older: "var(--muted-foreground)",
export const RECENCY_COLORS: Record<ActiveRecencyBucket, string> = {
today: "var(--recency-today)",
last_7_days: "var(--recency-last-7-days)",
last_30_days: "var(--recency-last-30-days)",
};

export function isActiveExpenseSuggestion(
suggestion: ExpenseSuggestion,
): suggestion is ActiveExpenseSuggestion {
return suggestion.recencyBucket !== "older";
}
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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<ActiveExpenseSuggestion[]> {
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 {
Expand All @@ -31,15 +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.slice(0, pageSize);
setState({
status: suggestions.length > 0 ? "success" : "empty",
suggestions,
Expand Down
9 changes: 9 additions & 0 deletions frontend/packages/ui/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading