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")} +
+ ) : ( + <> + + + + + )} )}