fix(transactions): show the import-first card on the count alone (#384)

D1 of Max: showsEmptyState of Transactions reads totalInDb === 0 only, like
Budget. On an empty profile the card comes up as soon as the count is
loaded, instead of the filter bar, the totals and the loading line until
the first page is back. A null count (not loaded, or failed) keeps the
regular layout.

The mount reads of useTransactions and useBudget run under
Promise.allSettled: each read that succeeds is dispatched, the first
failure becomes the page's error, and a failed count leaves totalInDb null
instead of emptying the category, source and account filters.

Tests: the reducer test of the loading page rewritten for D1, the page's
empty-profile case rendered (card in place of the filter bar, table and
pagination), and both hooks' mount reads run through the shared harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-18 20:30:42 -04:00
parent 11eb911657
commit 3d917ed803
6 changed files with 331 additions and 29 deletions

View file

@ -428,17 +428,23 @@ export function useBudget() {
// 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).
// useTrends.ts) and the profile-wide transaction count (#353). Each read
// stands alone (#384): a failed count no longer leaves the account filter
// empty, and leaves `totalInDb` null, so the grid, never the empty state.
// The first failure is the page's error.
useEffect(() => {
(async () => {
try {
const [accounts, total] = await Promise.all([
getAllImportSources(),
getTransactionCount(),
]);
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
} catch (e) {
const [accounts, total] = await Promise.allSettled([
getAllImportSources(),
getTransactionCount(),
]);
if (accounts.status === "fulfilled") dispatch({ type: "SET_ACCOUNTS", payload: accounts.value });
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
const failed = [accounts, total].find(
(r): r is PromiseRejectedResult => r.status === "rejected"
);
if (failed) {
const e: unknown = failed.reason;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();

View file

@ -0,0 +1,97 @@
// useBudget — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the year's budget, then the accounts and the count). The
// services and the reports period are mocked. What is pinned: a failed count
// no longer drops the accounts of the filter, leaves `totalInDb` unknown (the
// grid, never the "import first" card), and the first failure, in the order
// accounts, count, is the page's error.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
}));
vi.mock("../services/transactionService", () => services);
vi.mock("../services/budgetService", () => ({
getAllActiveCategories: vi.fn(async () => []),
getBudgetEntriesForYear: vi.fn(async () => []),
getActualTotalsForYear: vi.fn(async () => []),
getAllTemplates: vi.fn(async () => []),
upsertBudgetEntry: vi.fn(),
upsertBudgetEntriesForYear: vi.fn(),
saveAsTemplate: vi.fn(),
applyTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
// One array for every render: a new one would read as a changed filter.
const { NO_ACCOUNT } = vi.hoisted(() => ({ NO_ACCOUNT: [] as number[] }));
vi.mock("./useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: NO_ACCOUNT, setAccountIds: () => undefined }),
}));
import { hookHarness } from "../test/hookHarness";
import { useBudget, showsEmptyState } from "./useBudget";
import type { ImportSource } from "../shared/types";
const harness = hookHarness();
const ACCOUNTS = [{ id: 7, name: "Desjardins" }] as ImportSource[];
/** Lets the mount reads settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(useBudget);
harness.commit();
await flush();
return harness.render(useBudget).state;
}
beforeEach(() => {
harness.clear();
services.getAllImportSources.mockReset().mockResolvedValue(ACCOUNTS);
services.getTransactionCount.mockReset().mockResolvedValue(0);
});
describe("useBudget — the reads of the mount (#384)", () => {
it("both succeed: the accounts and the count, no error", async () => {
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the accounts, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("count failed");
// Unknown count: the grid, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed accounts still load the count", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockResolvedValue(41);
const state = await mount();
expect(state.accounts).toEqual([]);
expect(state.totalInDb).toBe(41);
expect(state.error).toBe("accounts failed");
});
it("both fail: the accounts' failure, the first one, is the error", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("accounts failed");
});
});

View file

@ -67,14 +67,19 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
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.
it("shows the empty state as soon as the count is 0, even while the first page loads (#384, D1)", () => {
// Same rule as Budget: the count alone decides. On an empty profile the
// card comes up as soon as the count is known, instead of the filter bar,
// the totals and the loading line until the first page is back.
const loadingEmpty = transactionsReducer(
transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }),
{ type: "SET_LOADING", payload: true },
);
expect(showsEmptyState(loadingEmpty)).toBe(false);
expect(loadingEmpty.isLoading).toBe(true);
expect(showsEmptyState(loadingEmpty)).toBe(true);
// And while the count is unknown, a loading page keeps the regular layout.
const loadingUnknown = transactionsReducer(initialTransactionsState, { type: "SET_LOADING", payload: true });
expect(showsEmptyState(loadingUnknown)).toBe(false);
});
it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => {

View file

@ -207,11 +207,13 @@ export function transactionsReducer(
/**
* 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.
* transition is unit-testable without a renderer. `null` (count not loaded,
* or its read failed) keeps the regular layout. A 0 shows the card at once,
* even while the first page is still loading: the same rule as Budget, so an
* empty profile no longer gets the table's layout first (#384, D1).
*/
export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "isLoading">): boolean {
return state.totalInDb === 0 && !state.isLoading;
export function showsEmptyState(state: Pick<TransactionsState, "totalInDb">): boolean {
return state.totalInDb === 0;
}
/**
@ -225,19 +227,26 @@ export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters);
// Load categories, sources and the profile-wide transaction count once on mount
// Load categories, sources and the profile-wide transaction count once on
// mount. Each read stands alone (#384): a failed count no longer leaves the
// category and source filters empty, and leaves `totalInDb` null, so the
// regular layout, never the empty state. The first failure is the page's
// error.
useEffect(() => {
(async () => {
try {
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) {
const [cats, srcs, total] = await Promise.allSettled([
getAllCategories(),
getAllImportSources(),
getTransactionCount(),
]);
if (cats.status === "fulfilled") dispatch({ type: "SET_CATEGORIES", payload: cats.value });
if (srcs.status === "fulfilled") dispatch({ type: "SET_SOURCES", payload: srcs.value });
if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
const failed = [cats, srcs, total].find(
(r): r is PromiseRejectedResult => r.status === "rejected"
);
if (failed) {
const e: unknown = failed.reason;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),

View file

@ -0,0 +1,121 @@
// useTransactions — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the mount reads, the first page, the search debounce,
// whose timer `unmount()` clears). The services are mocked. What is pinned:
// one failed read no longer drops the others, a failed count leaves
// `totalInDb` unknown (the regular layout, never the "import first" card),
// and the first failure, in the order categories, sources, count, is the
// page's error.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
getTransactionPage: vi.fn(),
}));
vi.mock("../services/transactionService", () => ({
...services,
updateTransactionCategory: vi.fn(),
updateTransactionNotes: vi.fn(),
autoCategorizeTransactions: vi.fn(),
getSplitChildren: vi.fn(),
saveSplitAdjustment: vi.fn(),
deleteSplitAdjustment: vi.fn(),
}));
vi.mock("../services/categoryService", () => ({ createKeyword: vi.fn() }));
import { hookHarness } from "../test/hookHarness";
import { useTransactions, showsEmptyState } from "./useTransactions";
import type { Category, ImportSource, TransactionPageResult } from "../shared/types";
const harness = hookHarness();
const CATEGORIES = [{ id: 1, name: "Épicerie" }] as Category[];
const SOURCES = [{ id: 7, name: "Desjardins" }] as ImportSource[];
const PAGE: TransactionPageResult = {
rows: [],
totalCount: 0,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
};
/** Lets the mount reads and the first page settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(() => useTransactions());
harness.commit();
await flush();
return harness.render(() => useTransactions()).state;
}
/** The actions of one type the hook dispatched, in order. */
const sent = (type: string) =>
harness.dispatch.mock.calls
.map(([action]) => action as { type: string; payload?: unknown })
.filter((a) => a.type === type);
beforeEach(() => {
harness.clear();
services.getAllCategories.mockReset().mockResolvedValue(CATEGORIES);
services.getAllImportSources.mockReset().mockResolvedValue(SOURCES);
services.getTransactionCount.mockReset().mockResolvedValue(0);
services.getTransactionPage.mockReset().mockResolvedValue(PAGE);
});
afterEach(() => {
harness.unmount();
});
describe("useTransactions — the reads of the mount (#384)", () => {
it("all three succeed: categories, sources and the count, no error", async () => {
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the categories and the sources, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBeNull();
expect(sent("SET_TOTAL_IN_DB")).toEqual([]);
expect(state.error).toBe("count failed");
// Unknown count: the regular layout, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed categories still load the sources and the count", async () => {
services.getAllCategories.mockRejectedValue(new Error("categories failed"));
services.getTransactionCount.mockResolvedValue(12);
const state = await mount();
expect(state.categories).toEqual([]);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(12);
expect(state.error).toBe("categories failed");
});
it("several failures: the first one, in the order categories, sources, count, is the error", async () => {
services.getAllImportSources.mockRejectedValue("sources failed");
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toEqual([]);
expect(state.totalInDb).toBeNull();
// A rejection that is not an Error is shown as its text.
expect(state.error).toBe("sources failed");
expect(sent("SET_ERROR").filter((a) => a.payload !== null)).toHaveLength(1);
});
});

View file

@ -20,6 +20,8 @@ const harness = vi.hoisted(() => ({
rows: [] as TransactionRow[],
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
filters: null as Partial<TransactionFilters> | null,
/** Other fields a read would have set: the profile-wide count, a page loading (#384). */
state: {} as { totalInDb?: number | null; isLoading?: boolean },
/** The license gate the page reads (#369), and the features it asked for. */
entitlement: { allowed: true, ready: true },
entitlementCalls: [] as string[],
@ -39,7 +41,7 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args);
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
return { ...hook, state: { ...hook.state, rows: harness.rows, filters } };
return { ...hook, state: { ...hook.state, ...harness.state, rows: harness.rows, filters } };
},
};
});
@ -140,6 +142,7 @@ beforeEach(() => {
harness.onboarding = path(IMPORTED);
harness.rows = [];
harness.filters = null;
harness.state = {};
harness.entitlement = { allowed: true, ready: true };
harness.entitlementCalls = [];
});
@ -305,3 +308,64 @@ describe("TransactionsPage — the Répartition button follows the edition (#369
}
});
});
describe("TransactionsPage — a profile without transactions (#353, #384)", () => {
/** An empty profile: the path's next step is elsewhere, so no step card here. */
const EMPTY: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
const CTA = /<a [^>]*href="\/import"[^>]*>t\(transactions\.empty\.cta\)<\/a>/;
/** What only the regular layout renders: filter bar, totals, table, pagination, loading line. */
const REGULAR = [
"t(transactions.filters.searchPlaceholder)",
"t(transactions.summary.count)",
"t(transactions.noTransactions)",
"t(transactions.pagination.showing)",
"t(common.loading)",
];
beforeEach(() => {
harness.onboarding = path(EMPTY, { mode: "banner", cardPage: null });
});
it("the 'import first' card in place of the filter bar, the table and the pagination", () => {
harness.state = { totalInDb: 0 };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).toContain("t(transactions.empty.description)");
expect(html).toMatch(CTA);
for (const marker of REGULAR) expect(html, marker).not.toContain(marker);
expect(html).not.toContain("<table");
// The page's heading stays above the card.
expect(html.indexOf("t(transactions.title)")).toBeLessThan(html.indexOf("t(transactions.empty.title)"));
});
it("as soon as the count is known, while the first page still loads (D1)", () => {
harness.state = { totalInDb: 0, isLoading: true };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).not.toContain("t(common.loading)");
expect(html).not.toContain("t(transactions.filters.searchPlaceholder)");
});
it("count not loaded yet, or its read failed: the regular layout, never the card", () => {
for (const state of [{ totalInDb: null }, { totalInDb: null, isLoading: true }]) {
harness.state = state;
const html = render();
expect(html, JSON.stringify(state)).not.toContain("transactions.empty");
expect(html, JSON.stringify(state)).toContain("t(transactions.filters.searchPlaceholder)");
}
});
it("a populated profile with an empty page keeps the table's layout (default filter: since January 1st)", () => {
harness.state = { totalInDb: 7 };
const html = render();
expect(html).not.toContain("transactions.empty");
expect(html).toContain("t(transactions.noTransactions)");
});
});