feat(onboarding): empty states on the Transactions and Budget pages (#353) #379

Merged
maximus merged 1 commit from issue-353-transactions-budget-empty into main 2026-10-04 12:51:28 +00:00
8 changed files with 315 additions and 65 deletions
Showing only changes of commit 8eac47f69b - Show all commits

View file

@ -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);
});
});

View file

@ -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<BudgetState, "totalInDb">): 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) });
}

View file

@ -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);
});
});

View file

@ -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<TransactionsState, "totalInDb" | "isLoading">): 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<ReturnType<typeof setTimeout> | 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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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 (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
@ -40,6 +47,21 @@ export default function BudgetPage() {
/>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error}
</div>
)}
{isEmptyProfile ? (
<EmptyState
icon={<Calculator size={40} />}
title={t("budget.empty.title")}
description={t("budget.empty.description")}
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
/>
) : (
<>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
@ -47,17 +69,13 @@ export default function BudgetPage() {
accounts={accounts}
/>
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error}
</div>
)}
<BudgetTable
rows={rows}
onUpdatePlanned={updatePlanned}
onSplitEvenly={splitEvenly}
/>
</>
)}
</div>
);
}

View file

@ -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 (
<div>
<div className="relative flex items-center gap-3 mb-6">
@ -73,6 +80,21 @@ export default function TransactionsPage() {
)}
</div>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
{state.error}
</div>
)}
{isEmptyProfile ? (
<EmptyState
icon={<Receipt size={40} />}
title={t("transactions.empty.title")}
description={t("transactions.empty.description")}
primaryAction={{ label: t("transactions.empty.cta"), to: "/import" }}
/>
) : (
<>
<TransactionFilterBar
filters={state.filters}
categories={state.categories}
@ -87,12 +109,6 @@ export default function TransactionsPage() {
expenseTotal={state.expenseTotal}
/>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
{state.error}
</div>
)}
{state.isLoading ? (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{t("common.loading")}
@ -122,6 +138,8 @@ export default function TransactionsPage() {
/>
</>
)}
</>
)}
{menu && (
<ContextMenu