From 8eac47f69bdf572f28a1d5908e8bc60fd90f9fda Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 3 Oct 2026 19:40:45 -0400 Subject: [PATCH] feat(onboarding): empty states on the Transactions and Budget pages (#353) A profile without any transaction now gets an "import first" card with a button to the import page on both pages, in place of the filter bar, the table and the pagination (Transactions) and of the filter panel and the grid (Budget). Title, page help and template actions stay. The signal is a profile-wide `totalInDb` count loaded once at mount via `getTransactionCount()`, not `rows.length`: the default Transactions filter is "since January 1st", so an empty page cannot tell an empty profile from a year without transactions. `null` (not loaded yet) keeps the regular layout, and so does a loading Transactions page. `transactionsReducer` / `initialTransactionsState` and `budgetReducer` / `initialBudgetState` are exported with a pure `showsEmptyState` selector per hook, so the reducer tests pin that the count never touches filters, sort or page and that 0 then N > 0 brings the table back. The existing "no transactions found" / "no categories found" rows are unchanged. Resolves #353 Co-Authored-By: Claude Fable 5.1 --- src/hooks/useBudget.test.ts | 48 ++++++++++++++++ src/hooks/useBudget.ts | 43 +++++++++++--- src/hooks/useTransactions.test.ts | 94 +++++++++++++++++++++++++++++++ src/hooks/useTransactions.ts | 41 +++++++++++--- src/i18n/locales/en.json | 10 ++++ src/i18n/locales/fr.json | 10 ++++ src/pages/BudgetPage.tsx | 44 ++++++++++----- src/pages/TransactionsPage.tsx | 90 +++++++++++++++++------------ 8 files changed, 315 insertions(+), 65 deletions(-) create mode 100644 src/hooks/useTransactions.test.ts diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts index c6b3088..44e5236 100644 --- a/src/hooks/useBudget.test.ts +++ b/src/hooks/useBudget.test.ts @@ -128,3 +128,51 @@ describe("BudgetTable section subtotals stay on RAW rows (collapse is purely vis expect(rawExpense.annual).not.toBe(visibleExpense.annual); }); }); + +// --------------------------------------------------------------------------- +// Issue #353 — the profile-wide transaction count (`totalInDb`) drives the +// "import first" empty state of the Budget page. The exported reducer is +// exercised directly (no renderHook harness in this repo). + +import { budgetReducer, initialBudgetState, showsEmptyState } from "./useBudget"; + +describe("budgetReducer — SET_TOTAL_IN_DB (issue #353)", () => { + it("starts unknown: totalInDb is null, which keeps the grid", () => { + const state = initialBudgetState(); + expect(state.totalInDb).toBeNull(); + expect(showsEmptyState(state)).toBe(false); + }); + + it("stores the count without touching year, rows, templates or accounts", () => { + const before = budgetReducer( + budgetReducer(initialBudgetState(), { type: "SET_YEAR", payload: 2024 }), + { type: "SET_DATA", payload: { rows: buildBudgetYearRows(CATEGORIES, [entry(2, 1000)], []), templates: [] } }, + ); + const after = budgetReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 }); + + expect(after.totalInDb).toBe(0); + expect(after.year).toBe(2024); + expect(after.rows).toBe(before.rows); + expect(after.templates).toBe(before.templates); + expect(after.accounts).toBe(before.accounts); + expect({ ...after, totalInDb: before.totalInDb }).toEqual(before); + }); + + it("0 shows the empty state, then N > 0 renders the grid again", () => { + const empty = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 0 }); + expect(showsEmptyState(empty)).toBe(true); + + const filled = budgetReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 12 }); + expect(filled.totalInDb).toBe(12); + expect(showsEmptyState(filled)).toBe(false); + }); + + it("the other actions preserve totalInDb", () => { + const known = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 3 }); + const after = budgetReducer( + budgetReducer(known, { type: "SET_YEAR", payload: 2025 }), + { type: "SET_ERROR", payload: "boom" }, + ); + expect(after.totalInDb).toBe(3); + }); +}); diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index 6564186..a476293 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -11,10 +11,10 @@ import { applyTemplate as applyTemplateSvc, deleteTemplate as deleteTemplateSvc, } from "../services/budgetService"; -import { getAllImportSources } from "../services/transactionService"; +import { getAllImportSources, getTransactionCount } from "../services/transactionService"; import { useReportsPeriod } from "./useReportsPeriod"; -interface BudgetState { +export interface BudgetState { year: number; rows: BudgetYearRow[]; templates: BudgetTemplate[]; @@ -22,17 +22,25 @@ interface BudgetState { isLoading: boolean; isSaving: boolean; error: string | null; + /** + * Number of transactions in the whole profile (#353), `null` until loaded. + * The page shows the "import first" empty state when it is 0, instead of + * a budget grid with nothing to compare against. + */ + totalInDb: number | null; } -type BudgetAction = +export type BudgetAction = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_ACCOUNTS"; payload: ImportSource[] } + | { type: "SET_TOTAL_IN_DB"; payload: number } | { type: "SET_YEAR"; payload: number }; -function initialState(): BudgetState { +/** Lazy initial state: `year` is read at mount time, not at module load. */ +export function initialBudgetState(): BudgetState { return { year: new Date().getFullYear(), rows: [], @@ -41,10 +49,11 @@ function initialState(): BudgetState { isLoading: false, isSaving: false, error: null, + totalInDb: null, }; } -function reducer(state: BudgetState, action: BudgetAction): BudgetState { +export function budgetReducer(state: BudgetState, action: BudgetAction): BudgetState { switch (action.type) { case "SET_LOADING": return { ...state, isLoading: action.payload }; @@ -61,6 +70,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { }; case "SET_ACCOUNTS": return { ...state, accounts: action.payload }; + case "SET_TOTAL_IN_DB": + return { ...state, totalInDb: action.payload }; case "SET_YEAR": return { ...state, year: action.payload }; default: @@ -371,9 +382,18 @@ export function buildBudgetYearRows( return rows; } +/** + * Whether the Budget page shows the "import first" empty state (#353) instead + * of the filter panel and the grid. Pure, so the 0 → N transition is + * unit-testable without a renderer. `null` (count not loaded) keeps the grid. + */ +export function showsEmptyState(state: Pick): boolean { + return state.totalInDb === 0; +} + export function useBudget() { const { accountIds } = useReportsPeriod(); - const [state, dispatch] = useReducer(reducer, undefined, initialState); + const [state, dispatch] = useReducer(budgetReducer, undefined, initialBudgetState); const fetchIdRef = useRef(0); const refreshData = useCallback(async (year: number, ids: number[]) => { @@ -407,13 +427,18 @@ export function useBudget() { refreshData(state.year, accountIds); }, [state.year, accountIds, refreshData]); - // Load the import-source list once, for the FilterPanel's account checkboxes - // (same query as the Trends/Compare source filters — see useTrends.ts). + // Load once on mount: the import-source list for the FilterPanel's account + // checkboxes (same query as the Trends/Compare source filters — see + // useTrends.ts) and the profile-wide transaction count (#353). useEffect(() => { (async () => { try { - const accounts = await getAllImportSources(); + const [accounts, total] = await Promise.all([ + getAllImportSources(), + getTransactionCount(), + ]); dispatch({ type: "SET_ACCOUNTS", payload: accounts }); + dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); } diff --git a/src/hooks/useTransactions.test.ts b/src/hooks/useTransactions.test.ts new file mode 100644 index 0000000..b2bcfb5 --- /dev/null +++ b/src/hooks/useTransactions.test.ts @@ -0,0 +1,94 @@ +// useTransactions — reducer unit tests (issue #353). +// +// The project has no jsdom / renderHook harness, so the EXPORTED reducer and +// its initial state are exercised directly (same approach as +// useSnapshotEditor.test.ts and useBudget.test.ts). What is pinned here: the +// profile-wide transaction count (`totalInDb`) is orthogonal to the paging +// state — setting it never touches `filters`, `sort` or `page` — and the +// 0 → N transition takes the page from the empty state back to the table. + +import { describe, it, expect } from "vitest"; +import { + transactionsReducer, + initialTransactionsState, + showsEmptyState, + type TransactionsState, +} from "./useTransactions"; + +const populated = (): TransactionsState => + transactionsReducer( + transactionsReducer( + transactionsReducer(initialTransactionsState, { + type: "SET_FILTER", + payload: { key: "search", value: "epicerie" }, + }), + { type: "SET_SORT", payload: { column: "amount", direction: "asc" } }, + ), + { type: "SET_PAGE", payload: 3 }, + ); + +describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => { + it("starts unknown: totalInDb is null, which keeps the regular layout", () => { + expect(initialTransactionsState.totalInDb).toBeNull(); + expect(showsEmptyState(initialTransactionsState)).toBe(false); + }); + + it("stores the count without touching filters, sort or page", () => { + const before = populated(); + const after = transactionsReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 }); + + expect(after.totalInDb).toBe(0); + // Same references, not merely deep-equal: no hidden reset of the paging state. + expect(after.filters).toBe(before.filters); + expect(after.sort).toBe(before.sort); + expect(after.page).toBe(before.page); + expect(after.filters.search).toBe("epicerie"); + expect(after.sort).toEqual({ column: "amount", direction: "asc" }); + expect(after.page).toBe(3); + // And nothing else moved either. + expect({ ...after, totalInDb: before.totalInDb }).toEqual(before); + }); + + it("0 shows the empty state, then N > 0 renders the table again", () => { + const empty = transactionsReducer(initialTransactionsState, { + type: "SET_TOTAL_IN_DB", + payload: 0, + }); + expect(showsEmptyState(empty)).toBe(true); + + const filled = transactionsReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 42 }); + expect(filled.totalInDb).toBe(42); + expect(showsEmptyState(filled)).toBe(false); + }); + + it("keeps the regular layout while a page is loading, even at 0", () => { + // The loading placeholder stays in place of the table so the card does not + // flash during the first fetch of a profile whose count is already known. + const loadingEmpty = transactionsReducer( + transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }), + { type: "SET_LOADING", payload: true }, + ); + expect(showsEmptyState(loadingEmpty)).toBe(false); + }); + + it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => { + const state = transactionsReducer( + transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 7 }), + { + type: "SET_PAGE_RESULT", + payload: { rows: [], totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, + }, + ); + expect(state.rows).toHaveLength(0); + expect(showsEmptyState(state)).toBe(false); + }); + + it("the other actions preserve totalInDb", () => { + const known = transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 5 }); + const after = transactionsReducer( + transactionsReducer(known, { type: "SET_FILTER", payload: { key: "uncategorizedOnly", value: true } }), + { type: "SET_ERROR", payload: "boom" }, + ); + expect(after.totalInDb).toBe(5); + }); +}); diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index dac699c..bddfef2 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -14,6 +14,7 @@ import { updateTransactionNotes, getAllCategories, getAllImportSources, + getTransactionCount, autoCategorizeTransactions, getSplitChildren, saveSplitAdjustment, @@ -21,7 +22,7 @@ import { } from "../services/transactionService"; import { createKeyword } from "../services/categoryService"; -interface TransactionsState { +export interface TransactionsState { rows: TransactionRow[]; totalCount: number; totalAmount: number; @@ -36,9 +37,16 @@ interface TransactionsState { isLoading: boolean; isAutoCategorizing: boolean; error: string | null; + /** + * Number of transactions in the whole profile, independent of the filters + * (#353). `null` until the count is loaded. The page needs it because the + * default filter is "since January 1st", so an empty `rows` cannot tell a + * profile without any transaction from a year without any. + */ + totalInDb: number | null; } -type TransactionsAction = +export type TransactionsAction = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_PAGE_RESULT"; payload: TransactionPageResult } @@ -47,6 +55,7 @@ type TransactionsAction = | { type: "SET_PAGE"; payload: number } | { type: "SET_CATEGORIES"; payload: Category[] } | { type: "SET_SOURCES"; payload: ImportSource[] } + | { type: "SET_TOTAL_IN_DB"; payload: number } | { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } } | { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } } | { type: "SET_AUTO_CATEGORIZING"; payload: boolean }; @@ -60,7 +69,7 @@ const initialFilters: TransactionFilters = { uncategorizedOnly: false, }; -const initialState: TransactionsState = { +export const initialTransactionsState: TransactionsState = { rows: [], totalCount: 0, totalAmount: 0, @@ -75,9 +84,13 @@ const initialState: TransactionsState = { isLoading: false, isAutoCategorizing: false, error: null, + totalInDb: null, }; -function reducer(state: TransactionsState, action: TransactionsAction): TransactionsState { +export function transactionsReducer( + state: TransactionsState, + action: TransactionsAction +): TransactionsState { switch (action.type) { case "SET_LOADING": return { ...state, isLoading: action.payload }; @@ -107,6 +120,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact return { ...state, categories: action.payload }; case "SET_SOURCES": return { ...state, sources: action.payload }; + case "SET_TOTAL_IN_DB": + return { ...state, totalInDb: action.payload }; case "UPDATE_ROW_CATEGORY": return { ...state, @@ -136,22 +151,34 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact } } +/** + * Whether the Transactions page shows the "import first" empty state (#353) + * instead of the filter bar, table and pagination. Pure, so the 0 → N + * transition is unit-testable without a renderer. `null` (count not loaded) + * and a loading page keep the regular layout. + */ +export function showsEmptyState(state: Pick): boolean { + return state.totalInDb === 0 && !state.isLoading; +} + export function useTransactions() { - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState); const fetchIdRef = useRef(0); const searchTimerRef = useRef | null>(null); const debouncedFiltersRef = useRef(state.filters); - // Load categories and sources once on mount + // Load categories, sources and the profile-wide transaction count once on mount useEffect(() => { (async () => { try { - const [cats, srcs] = await Promise.all([ + const [cats, srcs, total] = await Promise.all([ getAllCategories(), getAllImportSources(), + getTransactionCount(), ]); dispatch({ type: "SET_CATEGORIES", payload: cats }); dispatch({ type: "SET_SOURCES", payload: srcs }); + dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); } catch (e) { dispatch({ type: "SET_ERROR", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a3aa770..13e3047 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -319,6 +319,11 @@ "transferIcon": { "tooltip": "Linked to a balance account", "ariaLabel": "Transaction linked to a balance account" + }, + "empty": { + "title": "No transactions yet", + "description": "Import your bank statements first.", + "cta": "Import transactions" } }, "categories": { @@ -424,6 +429,11 @@ "Use the split button to distribute the annual total evenly across all months", "Save your budget as a template and apply it to specific months or all 12 at once" ] + }, + "empty": { + "title": "Your budget is built on your transactions", + "description": "Import your bank statements first.", + "cta": "Import transactions" } }, "reports": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index fd88eac..3f0039a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -319,6 +319,11 @@ "transferIcon": { "tooltip": "Liée à un compte de bilan", "ariaLabel": "Transaction liée à un compte de bilan" + }, + "empty": { + "title": "Aucune transaction pour l'instant", + "description": "Importez d'abord vos relevés bancaires.", + "cta": "Importer des transactions" } }, "categories": { @@ -424,6 +429,11 @@ "Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois", "Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup" ] + }, + "empty": { + "title": "Le budget se construit sur vos transactions", + "description": "Importez d'abord vos relevés bancaires.", + "cta": "Importer des transactions" } }, "reports": { diff --git a/src/pages/BudgetPage.tsx b/src/pages/BudgetPage.tsx index fbf82ba..2b4ee03 100644 --- a/src/pages/BudgetPage.tsx +++ b/src/pages/BudgetPage.tsx @@ -1,7 +1,9 @@ import { useTranslation } from "react-i18next"; +import { Calculator } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; +import EmptyState from "../components/shared/EmptyState"; import FilterPanel from "../components/reports/FilterPanel"; -import { useBudget } from "../hooks/useBudget"; +import { useBudget, showsEmptyState } from "../hooks/useBudget"; import { useReportsPeriod } from "../hooks/useReportsPeriod"; import YearNavigator from "../components/budget/YearNavigator"; import BudgetTable from "../components/budget/BudgetTable"; @@ -23,6 +25,11 @@ export default function BudgetPage() { const { year, rows, templates, accounts, isLoading, isSaving, error } = state; + // #353 — a profile without any transaction gets the "import first" card in + // place of the filter panel and the grid; the title, help and template + // actions stay. + const isEmptyProfile = showsEmptyState(state); + return (
@@ -40,24 +47,35 @@ export default function BudgetPage() { />
- } - accountIds={accountIds} - onAccountIdsChange={setAccountIds} - accounts={accounts} - /> - {error && (
{error}
)} - + {isEmptyProfile ? ( + } + title={t("budget.empty.title")} + description={t("budget.empty.description")} + primaryAction={{ label: t("budget.empty.cta"), to: "/import" }} + /> + ) : ( + <> + } + accountIds={accountIds} + onAccountIdsChange={setAccountIds} + accounts={accounts} + /> + + + + )}
); } diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index 0b846fd..abbda16 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Wand2, Tag } from "lucide-react"; +import { Wand2, Tag, Receipt } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; -import { useTransactions } from "../hooks/useTransactions"; +import EmptyState from "../components/shared/EmptyState"; +import { useTransactions, showsEmptyState } from "../hooks/useTransactions"; import TransactionFilterBar from "../components/transactions/TransactionFilterBar"; import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar"; import TransactionTable from "../components/transactions/TransactionTable"; @@ -51,6 +52,12 @@ export default function TransactionsPage() { setTimeout(() => setResultMessage(null), 4000); }; + // #353 — a profile without any transaction gets the "import first" card in + // place of the filter bar, table and pagination. `rows.length === 0` is not + // the signal: the default filter is "since January 1st", so an empty page + // can still sit on a populated profile. + const isEmptyProfile = showsEmptyState(state); + return (
@@ -73,53 +80,64 @@ export default function TransactionsPage() { )}
- - - - {state.error && (
{state.error}
)} - {state.isLoading ? ( -
- {t("common.loading")} -
+ {isEmptyProfile ? ( + } + title={t("transactions.empty.title")} + description={t("transactions.empty.description")} + primaryAction={{ label: t("transactions.empty.cta"), to: "/import" }} + /> ) : ( <> - - + + {state.isLoading ? ( +
+ {t("common.loading")} +
+ ) : ( + <> + + + + + )} )}