feat(onboarding): the Transactions page's step card and its guided entry

The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.

`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.

TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 08:56:44 -04:00
parent ee26fe0888
commit fd44d8702b
12 changed files with 727 additions and 29 deletions

View file

@ -150,10 +150,7 @@ describe("AppShell — the getting-started banner (#397)", () => {
} }
}); });
it.each([ it.each([["/budget", BUDGET_LEFT]] as const)(
["/transactions", IMPORTED],
["/budget", BUDGET_LEFT],
] as const)(
"keeps the banner on %s, the page of the next step, while no step card is delivered", "keeps the banner on %s, the page of the next step, while no step card is delivered",
(pathname, snapshot) => { (pathname, snapshot) => {
expect(CARD_PAGES.has(pathname)).toBe(false); expect(CARD_PAGES.has(pathname)).toBe(false);
@ -176,6 +173,17 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"'); expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
}); });
it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true);
const value = pathAt("/transactions", loaded(IMPORTED));
expect(value.nextStep?.to).toBe("/transactions");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/transactions");
expect(renderWith("/transactions", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /transactions.
expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"');
});
it("shows nothing on a page that carries a step card", () => { it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" }; const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER); expect(renderWith("/import", value)).not.toContain(BANNER);

View file

@ -0,0 +1,108 @@
// TransactionTable — the getting-started target (#399). No jsdom:
// react-dom/server renders in vitest's node environment, and the markup says
// which element carries the highlight: a wrapper around the category of the
// first row shown, CategoryCombobox itself left as it is.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
import type { Category, TransactionRow } from "../../shared/types";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
const GROCERIES: Category = {
id: 7,
name: "Épicerie",
type: "expense",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({
id,
date: "2026-09-12",
description,
amount: -42.5,
category_id: category?.id ?? null,
category_name: category?.name ?? null,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: false,
is_split: false,
});
/** The guided entry: uncategorized rows only. */
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
renderToStaticMarkup(
<TransactionTable
rows={rows}
sort={{ column: "date", direction: "desc" }}
categories={[GROCERIES]}
onSort={vi.fn()}
onCategoryChange={vi.fn()}
onNotesChange={vi.fn()}
onAddKeyword={vi.fn(async () => undefined)}
onLoadSplitChildren={vi.fn(async () => [])}
onSaveSplit={vi.fn(async () => undefined)}
onDeleteSplit={vi.fn(async () => undefined)}
onboardingTarget={onboardingTarget}
/>,
);
/** The rows of the table's body, in order. */
const bodyRows = (html: string) => html.split("<tbody")[1].split("<tr").slice(1);
/** The highlighted wrapper around a closed combobox, whose input shows `value`. */
const ringedCategory = (value: string) =>
new RegExp(
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value}"[^>]*/></div></div>`,
);
describe("TransactionTable — getting-started target (#399)", () => {
it("highlights exactly one element: a wrapper around the first row's category", () => {
const html = markup(UNCATEGORIZED, true);
expect(count(html, HIGHLIGHT)).toBe(1);
const [first, ...others] = bodyRows(html);
expect(first).toContain("IGA 123");
expect(first).toMatch(ringedCategory("transactions.table.noCategory"));
expect(others).toHaveLength(2);
for (const other of others) {
expect(other).not.toContain("data-onboarding");
// Every row has the same wrapper, without the ring.
expect(other).toContain('<div class="rounded-lg"><div class="relative"><input');
}
});
it("rings the category only: a categorized first row keeps its keyword and split buttons outside", () => {
const html = markup([row(1, "IGA 123", GROCERIES), row(2, "METRO 456")], true);
expect(count(html, HIGHLIGHT)).toBe(1);
const [first] = bodyRows(html);
expect(first).toMatch(new RegExp(`${ringedCategory("Épicerie").source}<button`));
});
it("highlights nothing without the property", () => {
expect(markup(UNCATEGORIZED)).not.toContain("data-onboarding");
expect(markup(UNCATEGORIZED, false)).not.toContain("data-onboarding");
});
it("highlights nothing without a row: the card stays, without a ring", () => {
const html = markup([], true);
expect(html).toContain("transactions.noTransactions");
expect(html).not.toContain("data-onboarding");
});
});

View file

@ -8,6 +8,7 @@ import type {
SplitChild, SplitChild,
} from "../../shared/types"; } from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service"; import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import CategoryCombobox from "../shared/CategoryCombobox"; import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal"; import SplitAdjustmentModal from "./SplitAdjustmentModal";
@ -31,8 +32,16 @@ interface TransactionTableProps {
* to avoid an N+1 hit on the table render. * to avoid an N+1 hit on the table render.
*/ */
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>; linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
/**
* Highlight the category of the first row for the getting-started card
* (#399). The ring sits on a wrapper: `CategoryCombobox` stays as it is.
*/
onboardingTarget?: boolean;
} }
/** `data-onboarding-target` of the highlighted category. */
export const TRANSACTION_CATEGORY_TARGET = "transaction-category";
function SortIcon({ function SortIcon({
column, column,
sort, sort,
@ -62,6 +71,7 @@ export default function TransactionTable({
onDeleteSplit, onDeleteSplit,
onRowContextMenu, onRowContextMenu,
linkedTransfersByTxId, linkedTransfersByTxId,
onboardingTarget,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -144,7 +154,7 @@ export default function TransactionTable({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border)]"> <tbody className="divide-y divide-[var(--border)]">
{rows.map((row) => ( {rows.map((row, index) => (
<Fragment key={row.id}> <Fragment key={row.id}>
<tr <tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined} onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
@ -189,16 +199,29 @@ export default function TransactionTable({
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CategoryCombobox {/*
categories={categories} Every row has the wrapper and only the first one's carries
value={row.category_id} the ring (#399): the ring coming or going never remounts
onChange={(id) => onCategoryChange(row.id, id)} the combobox, so it never drops the focus.
placeholder={t("transactions.table.noCategory")} */}
compact <div
extraOptions={noCategoryExtra} className="rounded-lg"
activeExtra={row.category_id === null ? "" : null} {...onboardingTargetAttributes(
onExtraSelect={() => onCategoryChange(row.id, null)} TRANSACTION_CATEGORY_TARGET,
/> onboardingTarget && index === 0
)}
>
<CategoryCombobox
categories={categories}
value={row.category_id}
onChange={(id) => onCategoryChange(row.id, id)}
placeholder={t("transactions.table.noCategory")}
compact
extraOptions={noCategoryExtra}
activeExtra={row.category_id === null ? "" : null}
onExtraSelect={() => onCategoryChange(row.id, null)}
/>
</div>
{row.category_id !== null && ( {row.category_id !== null && (
<> <>
<button <button

View file

@ -1,4 +1,4 @@
// useTransactions — reducer unit tests (issue #353). // useTransactions — reducer unit tests (issues #353, #399).
// //
// The project has no jsdom / renderHook harness, so the EXPORTED reducer and // The project has no jsdom / renderHook harness, so the EXPORTED reducer and
// its initial state are exercised directly (same approach as // its initial state are exercised directly (same approach as
@ -6,14 +6,19 @@
// profile-wide transaction count (`totalInDb`) is orthogonal to the paging // profile-wide transaction count (`totalInDb`) is orthogonal to the paging
// state — setting it never touches `filters`, `sort` or `page` — and the // 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. // 0 → N transition takes the page from the empty state back to the table.
// Then the start filters (#399): the guided entry, its single fallback when
// no transaction is left uncategorized, and the ordinary visit.
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
transactionsReducer, transactionsReducer,
initialTransactionsState, initialTransactionsState,
initTransactionsState,
showsEmptyState, showsEmptyState,
GUIDED_ENTRY_FILTERS,
type TransactionsState, type TransactionsState,
} from "./useTransactions"; } from "./useTransactions";
import type { TransactionPageResult } from "../shared/types";
const populated = (): TransactionsState => const populated = (): TransactionsState =>
transactionsReducer( transactionsReducer(
@ -92,3 +97,118 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
expect(after.totalInDb).toBe(5); expect(after.totalInDb).toBe(5);
}); });
}); });
/** A page of `totalCount` transactions, as `getTransactionPage` returns it. */
const page = (totalCount: number): TransactionPageResult => ({
rows: [],
totalCount,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
});
const result = (state: TransactionsState, totalCount: number) =>
transactionsReducer(state, { type: "SET_PAGE_RESULT", payload: page(totalCount) });
describe("initTransactionsState — the start filters (#399)", () => {
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
for (const state of [initTransactionsState(), initTransactionsState(undefined)]) {
expect(state).toEqual(initialTransactionsState);
expect(state.filters).toEqual({
search: "",
categoryId: null,
sourceId: null,
dateFrom: JANUARY_FIRST,
dateTo: null,
uncategorizedOnly: false,
});
expect(state.guidedFallback).toBe(false);
}
});
it("the guided entry starts on the uncategorized transactions, without a date bound", () => {
expect(GUIDED_ENTRY_FILTERS).toEqual({ dateFrom: null, uncategorizedOnly: true });
const guided = initTransactionsState(GUIDED_ENTRY_FILTERS);
expect(guided.filters).toEqual({
search: "",
categoryId: null,
sourceId: null,
dateFrom: null,
dateTo: null,
uncategorizedOnly: true,
});
expect(guided.guidedFallback).toBe(true);
// Nothing else moves: same sort, first page, nothing loaded yet.
expect({ ...guided, filters: initialTransactionsState.filters, guidedFallback: false }).toEqual(
initialTransactionsState,
);
});
it("applies any start filter over the defaults; only 'uncategorized only' arms the fallback", () => {
const searched = initTransactionsState({ search: "epicerie" });
expect(searched.filters).toEqual({ ...initialTransactionsState.filters, search: "epicerie" });
expect(searched.guidedFallback).toBe(false);
expect(initTransactionsState({ uncategorizedOnly: false }).guidedFallback).toBe(false);
});
});
describe("transactionsReducer — the guided entry's fallback (#399)", () => {
const guided = () => initTransactionsState(GUIDED_ENTRY_FILTERS);
it("an empty first page drops 'uncategorized only', keeps the other start filters, and reads again", () => {
const before = guided();
const after = result(before, 0);
expect(after.filters).toEqual({ ...before.filters, uncategorizedOnly: false });
expect(after.filters.dateFrom).toBeNull();
// A new filters object is what makes the hook read again.
expect(after.filters).not.toBe(before.filters);
expect(after.page).toBe(1);
// The loading placeholder, not an empty table, until that read.
expect(after.isLoading).toBe(true);
expect(after.guidedFallback).toBe(false);
});
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
const fellBack = result(guided(), 0);
const again = result(fellBack, 0);
expect(again.filters).toBe(fellBack.filters);
expect(again.isLoading).toBe(false);
expect(again.guidedFallback).toBe(false);
expect(result(again, 0).filters).toBe(fellBack.filters);
});
it("a first page with uncategorized transactions keeps the filter, and a later empty page too", () => {
const before = guided();
const listed = result(before, 3);
expect(listed.filters).toBe(before.filters);
expect(listed.isLoading).toBe(false);
expect(listed.guidedFallback).toBe(false);
// Every one categorized, then a read comes back empty: the filter stays.
const emptied = result(listed, 0);
expect(emptied.filters).toBe(before.filters);
expect(emptied.filters.uncategorizedOnly).toBe(true);
});
it("a filter changed by the user before the first page is never dropped", () => {
const touched = transactionsReducer(guided(), {
type: "SET_FILTER",
payload: { key: "search", value: "epicerie" },
});
expect(touched.guidedFallback).toBe(false);
const after = result(touched, 0);
expect(after.filters).toBe(touched.filters);
expect(after.filters.uncategorizedOnly).toBe(true);
});
it("an ordinary visit never drops 'uncategorized only', even picked by hand and empty", () => {
const picked = transactionsReducer(initTransactionsState(), {
type: "SET_FILTER",
payload: { key: "uncategorizedOnly", value: true },
});
const after = result(picked, 0);
expect(after.filters).toBe(picked.filters);
expect(after.filters.uncategorizedOnly).toBe(true);
});
});

View file

@ -44,6 +44,13 @@ export interface TransactionsState {
* profile without any transaction from a year without any. * profile without any transaction from a year without any.
*/ */
totalInDb: number | null; totalInDb: number | null;
/**
* The start filters asked for the uncategorized transactions only (the
* guided entry, #399) and their first page has not come back yet. If it
* comes back empty, `SET_PAGE_RESULT` drops that filter, once. Off after
* that first page, after any filter change, and for an ordinary visit.
*/
guidedFallback: boolean;
} }
export type TransactionsAction = export type TransactionsAction =
@ -60,7 +67,7 @@ export type TransactionsAction =
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } } | { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean }; | { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
const initialFilters: TransactionFilters = { const defaultFilters: TransactionFilters = {
search: "", search: "",
categoryId: null, categoryId: null,
sourceId: null, sourceId: null,
@ -69,13 +76,24 @@ const initialFilters: TransactionFilters = {
uncategorizedOnly: false, uncategorizedOnly: false,
}; };
/**
* The guided entry of the "categorize by hand" step (#399): the uncategorized
* transactions, whatever their date. No date bound, because the default
* filter starts on January 1st and, in January, the sample statements fall in
* the previous year.
*/
export const GUIDED_ENTRY_FILTERS: Partial<TransactionFilters> = {
dateFrom: null,
uncategorizedOnly: true,
};
export const initialTransactionsState: TransactionsState = { export const initialTransactionsState: TransactionsState = {
rows: [], rows: [],
totalCount: 0, totalCount: 0,
totalAmount: 0, totalAmount: 0,
incomeTotal: 0, incomeTotal: 0,
expenseTotal: 0, expenseTotal: 0,
filters: initialFilters, filters: defaultFilters,
sort: { column: "date", direction: "desc" }, sort: { column: "date", direction: "desc" },
page: 1, page: 1,
pageSize: 50, pageSize: 50,
@ -85,8 +103,25 @@ export const initialTransactionsState: TransactionsState = {
isAutoCategorizing: false, isAutoCategorizing: false,
error: null, error: null,
totalInDb: null, totalInDb: null,
guidedFallback: false,
}; };
/**
* The hook's initial state (#399): the default filters with `initialFilters`
* over them. Start filters that ask for the uncategorized transactions only
* arm the fallback of their first page (`guidedFallback`). Without
* `initialFilters`, an ordinary visit: the default state.
*/
export function initTransactionsState(
initialFilters: Partial<TransactionFilters> = {}
): TransactionsState {
return {
...initialTransactionsState,
filters: { ...initialTransactionsState.filters, ...initialFilters },
guidedFallback: initialFilters.uncategorizedOnly === true,
};
}
export function transactionsReducer( export function transactionsReducer(
state: TransactionsState, state: TransactionsState,
action: TransactionsAction action: TransactionsAction
@ -96,8 +131,8 @@ export function transactionsReducer(
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
case "SET_PAGE_RESULT": case "SET_PAGE_RESULT": {
return { const next: TransactionsState = {
...state, ...state,
rows: action.payload.rows, rows: action.payload.rows,
totalCount: action.payload.totalCount, totalCount: action.payload.totalCount,
@ -105,12 +140,30 @@ export function transactionsReducer(
incomeTotal: action.payload.incomeTotal, incomeTotal: action.payload.incomeTotal,
expenseTotal: action.payload.expenseTotal, expenseTotal: action.payload.expenseTotal,
isLoading: false, isLoading: false,
guidedFallback: false,
}; };
// The guided entry's first page is empty: no transaction is left
// uncategorized (#399). Drop that filter, once, so the page lists every
// transaction instead of an empty table. The new filters make the hook
// read again (the filters' 300 ms debounce); until then, the loading
// placeholder takes the place of the empty table.
if (state.guidedFallback && action.payload.totalCount === 0) {
return {
...next,
filters: { ...state.filters, uncategorizedOnly: false },
page: 1,
isLoading: true,
};
}
return next;
}
case "SET_FILTER": case "SET_FILTER":
return { return {
...state, ...state,
filters: { ...state.filters, [action.payload.key]: action.payload.value }, filters: { ...state.filters, [action.payload.key]: action.payload.value },
page: 1, page: 1,
// The user's own filters: never dropped behind their back.
guidedFallback: false,
}; };
case "SET_SORT": case "SET_SORT":
return { ...state, sort: action.payload }; return { ...state, sort: action.payload };
@ -161,8 +214,13 @@ export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "is
return state.totalInDb === 0 && !state.isLoading; return state.totalInDb === 0 && !state.isLoading;
} }
export function useTransactions() { /**
const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState); * The Transactions page's state. `initialFilters` go over the default filters
* (the guided entry passes `GUIDED_ENTRY_FILTERS`, #399); like any initial
* state, they are read once, at mount, and a later value changes nothing.
*/
export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
const [state, dispatch] = useReducer(transactionsReducer, initialFilters, initTransactionsState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters); const debouncedFiltersRef = useRef(state.filters);

View file

@ -15,4 +15,9 @@ i18n.use(initReactI18next).init({
}, },
}); });
// `{{value, lowercase}}`: a label named inside a sentence (the "Uncategorized"
// filter of the Transactions step card, #399) keeps its own key and loses its
// capital, in each language's own way. The sentence decides, not the code.
i18n.services.formatter?.add("lowercase", (value, lng) => String(value).toLocaleLowerCase(lng));
export default i18n; export default i18n;

View file

@ -89,6 +89,10 @@
"confirm": "Click {{button}}: the source is saved and your transactions come in.", "confirm": "Click {{button}}: the source is saved and your transactions come in.",
"importing": "Importing…", "importing": "Importing…",
"failed": "The import added no transaction; the report below says why." "failed": "The import added no transaction; the report below says why."
},
"transactions": {
"uncategorized": "Only {{filter, lowercase}} transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
"all": "Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it."
} }
}, },
"state": { "state": {

View file

@ -89,6 +89,10 @@
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.", "confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
"importing": "Import en cours…", "importing": "Import en cours…",
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi." "failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
},
"transactions": {
"uncategorized": "Seules les transactions {{filter, lowercase}} sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
"all": "Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas."
} }
}, },
"state": { "state": {

View file

@ -0,0 +1,215 @@
// TransactionsPage and its getting-started card (#399). No jsdom:
// react-dom/server renders in vitest's node environment. The page runs the
// real `useTransactions` (its reducer and initial state; no effect runs, so
// nothing is read) with the rows a first read would have brought, and the path
// is a fake `useOnboarding()` built from the real checklist. What is pinned is
// the page's wiring: the guided entry's start filters, where the card sits,
// what it says, and which control carries the one highlight.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { TransactionRow } from "../shared/types";
// The page's services pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[] }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useTransactions", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
return {
...actual,
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args);
return { ...hook, state: { ...hook.state, rows: harness.rows } };
},
};
});
import TransactionsPage from "./TransactionsPage";
import { TRANSACTION_CATEGORY_TARGET } from "../components/transactions/TransactionTable";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep, stepNavState } from "../utils/onboardingPath";
import type { OnboardingCounters } from "../shared/types";
/** Folder, source and import done: the next step is the manual category. */
const IMPORTED: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 0,
budgetEntries: 0,
transactions: 41,
};
/** A transaction categorized by hand: the step is checked. */
const CATEGORIZED: OnboardingCounters = { ...IMPORTED, manualCategorized: 1 };
/** The path latched on /transactions (card mode), as the provider shares it. */
function path(
counters: OnboardingCounters,
overrides: Partial<OnboardingValue> = {},
budgetAllowed = true,
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/transactions",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
const row = (id: number, description: string): TransactionRow => ({
id,
date: "2025-12-18",
description,
amount: -42.5,
category_id: null,
category_name: null,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: false,
is_split: false,
});
/** What the link of the "categorize by hand" step carries. */
const GUIDED = stepNavState("manualCategory");
const render = (state: unknown = null) =>
renderToStaticMarkup(
<MemoryRouter initialEntries={[{ pathname: "/transactions", state }]}>
<TransactionsPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
const count = (html: string, needle: string) => html.split(needle).length - 1;
/**
* The card's sentence: the paragraph that follows its title. The card always
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
*/
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
/** The filter bar's start date, "" without a bound (React writes `value` last). */
const dateFrom = (html: string) =>
html.match(/<input type="date" placeholder="t\(transactions\.filters\.dateFrom\)"[^>]* value="([^"]*)"/)?.[1];
/** The period button shown as active. */
const activePeriod = (html: string) =>
html.match(/<button class="[^"]*bg-\[var\(--primary\)\] text-white">t\(dashboard\.period\.(\w+)\)<\/button>/)?.[1];
beforeEach(() => {
harness.onboarding = path(IMPORTED);
harness.rows = [];
});
describe("TransactionsPage — the guided entry (#399)", () => {
it("arriving by the step's link: the uncategorized transactions, without a date bound", () => {
const html = render(GUIDED);
expect(dateFrom(html)).toBe("");
expect(activePeriod(html)).toBe("all");
expect(html).toContain('value="t(transactions.filters.uncategorized)"');
});
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
const html = render();
expect(dateFrom(html)).toBe(JANUARY_FIRST);
expect(html).toContain('value="t(transactions.filters.allCategories)"');
expect(html).not.toContain('value="t(transactions.filters.uncategorized)"');
});
it("reads a malformed navigation state as an ordinary visit (CWE-476)", () => {
for (const state of ["guided", { guided: true }, { onboarding: null }, { onboarding: { guided: "yes" } }]) {
expect(dateFrom(render(state)), JSON.stringify(state)).toBe(JANUARY_FIRST);
}
});
});
describe("TransactionsPage — the step card (#399)", () => {
it("renders the card first, before the page's heading, at its place in the path", () => {
const html = render(GUIDED);
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
});
it("the guided entry: only the uncategorized transactions, the filter named by its own label", () => {
expect(sentence(render(GUIDED))).toBe(
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized)])",
);
});
it("an ordinary visit: the sentence for every transaction", () => {
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[])");
});
it("the first row's category is the one target", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
const html = render(GUIDED);
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
expect(ring).toBeGreaterThan(html.indexOf("IGA 123"));
expect(ring).toBeLessThan(html.indexOf("METRO 456"));
});
it("without the card on screen, no control is highlighted", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/import" },
]) {
harness.onboarding = path(IMPORTED, overrides);
const html = render(GUIDED);
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).toContain("IGA 123");
}
});
it("a transaction categorized by hand: the card stays, says 'Done.' and offers the Budget step", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
harness.onboarding = path(CATEGORIZED);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(
/<a [^>]*href="\/budget"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.budget\)\]\)/,
);
expect(sentence(html)).toBeNull();
expect(html).not.toContain("t(onboarding.card.skip)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("in the Free edition, the step checked ends the path", () => {
harness.onboarding = path(CATEGORIZED, {}, false);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain('href="/budget"');
});
});

View file

@ -1,9 +1,21 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wand2, Tag, Receipt } from "lucide-react"; import { Wand2, Tag, Receipt } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import EmptyState from "../components/shared/EmptyState"; import EmptyState from "../components/shared/EmptyState";
import { useTransactions, showsEmptyState } from "../hooks/useTransactions"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import {
readOnboardingNavState,
showsStepCard,
transactionsCardModel,
} from "../utils/onboardingPath";
import {
useTransactions,
showsEmptyState,
GUIDED_ENTRY_FILTERS,
} from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar"; import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar"; import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
import TransactionTable from "../components/transactions/TransactionTable"; import TransactionTable from "../components/transactions/TransactionTable";
@ -18,8 +30,16 @@ import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() { export default function TransactionsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
// The link of the "categorize by hand" step opens the guided entry (#399),
// read like any navigation state through `readOnboardingNavState` (CWE-476).
// The hook takes its start filters at mount only: the card consuming the
// navigation state afterwards changes nothing.
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } = const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
useTransactions(); useTransactions(
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
);
const onboarding = useOnboarding();
const [resultMessage, setResultMessage] = useState<string | null>(null); const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null); const [pending, setPending] = useState<TransactionRow | null>(null);
@ -58,8 +78,18 @@ export default function TransactionsPage() {
// can still sit on a populated profile. // can still sit on a populated profile.
const isEmptyProfile = showsEmptyState(state); const isEmptyProfile = showsEmptyState(state);
// The getting-started card follows the list's filter (#399). Its target is
// highlighted only while the card is on screen: a ring needs the card that
// explains it.
const cardModel = transactionsCardModel(state.filters, onboarding.checklist);
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
? cardModel.target
: null;
return ( return (
<div> <div>
<OnboardingStepCard model={cardModel} />
<div className="relative flex items-center gap-3 mb-6"> <div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1> <h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
<PageHelp helpKey="transactions" /> <PageHelp helpKey="transactions" />
@ -128,6 +158,7 @@ export default function TransactionsPage() {
onDeleteSplit={deleteSplit} onDeleteSplit={deleteSplit}
onRowContextMenu={handleRowContextMenu} onRowContextMenu={handleRowContextMenu}
linkedTransfersByTxId={linkedTransfersByTxId} linkedTransfersByTxId={linkedTransfersByTxId}
onboardingTarget={target === "firstRowCategory"}
/> />
<TransactionPagination <TransactionPagination

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import i18n from "../i18n/config";
import { computeChecklist, type Checklist } from "./onboardingChecklist"; import { computeChecklist, type Checklist } from "./onboardingChecklist";
import { import {
CARD_PAGES, CARD_PAGES,
@ -13,8 +14,11 @@ import {
showsStepCard, showsStepCard,
stepNavState, stepNavState,
stepsOfPage, stepsOfPage,
transactionsCardModel,
type BannerModeInput, type BannerModeInput,
type ImportCardModel, type ImportCardModel,
type StepCardText,
type TransactionsCardModel,
} from "./onboardingPath"; } from "./onboardingPath";
import { import {
CHECKLIST_ITEM_KEYS, CHECKLIST_ITEM_KEYS,
@ -342,10 +346,14 @@ describe("stepNavState (#397)", () => {
}); });
}); });
describe("CARD_PAGES (#398)", () => { describe("CARD_PAGES (#398, #399)", () => {
it("carries the Import page", () => { it("carries the Import page", () => {
expect(CARD_PAGES.has("/import")).toBe(true); expect(CARD_PAGES.has("/import")).toBe(true);
}); });
it("carries the Transactions page", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true);
});
}); });
describe("showsStepCard (#398)", () => { describe("showsStepCard (#398)", () => {
@ -569,6 +577,84 @@ describe("importCardModel (#398)", () => {
}); });
}); });
describe("transactionsCardModel (#399)", () => {
/** Folder, source and import done: the next step is the manual category. */
const imported = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true });
const UNCATEGORIZED: TransactionsCardModel = {
text: {
key: "onboarding.card.transactions.uncategorized",
labels: { filter: "transactions.filters.uncategorized" },
},
done: false,
target: "firstRowCategory",
};
const ALL: TransactionsCardModel = {
text: { key: "onboarding.card.transactions.all" },
done: false,
target: "firstRowCategory",
};
/** A sentence of the card as StepCard renders it, through the app's real i18n. */
const render = (text: StepCardText | null, lng: "fr" | "en") =>
text &&
i18n.t(text.key, {
lng,
...text.values,
...Object.fromEntries(
Object.entries(text.labels ?? {}).map(([name, key]) => [name, i18n.t(key, { lng })]),
),
});
it("only the uncategorized transactions listed: says so, the filter named by its own label", () => {
expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED);
});
it("every transaction listed: the sentence without the filter", () => {
expect(transactionsCardModel({ uncategorizedOnly: false }, imported)).toEqual(ALL);
});
it("points at the first row's category until the step is checked", () => {
expect(transactionsCardModel({ uncategorizedOnly: true }, null).target).toBe("firstRowCategory");
});
it("the step checked: 'Done.', nothing to point at, whatever the filter", () => {
const categorized = checklistOf(upToManual, { importFolderSet: true });
for (const uncategorizedOnly of [true, false]) {
expect(transactionsCardModel({ uncategorizedOnly }, categorized)).toEqual({
text: null,
done: true,
target: null,
});
}
});
it("a skipped step is not a checked one", () => {
const skipped = checklistOf(
{ ...zero, sources: 1, imports: 1, transactions: 41 },
{ importFolderSet: true, skipped: ["manualCategory"] },
);
expect(transactionsCardModel({ uncategorizedOnly: false }, skipped)).toEqual(ALL);
});
it("reads the two sentences in French, the filter's label in lowercase", () => {
expect(render(UNCATEGORIZED.text, "fr")).toBe(
"Seules les transactions non catégorisées sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
);
expect(render(ALL.text, "fr")).toBe(
"Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
);
});
it("reads the two sentences in English, the filter's label in lowercase", () => {
expect(render(UNCATEGORIZED.text, "en")).toBe(
"Only uncategorized transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
);
expect(render(ALL.text, "en")).toBe(
"Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
);
});
});
describe("onboardingTargetAttributes (#398)", () => { describe("onboardingTargetAttributes (#398)", () => {
it("marks the target and its highlight when on, nothing otherwise", () => { it("marks the target and its highlight when on, nothing otherwise", () => {
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({ expect(onboardingTargetAttributes("wizard-next", true)).toEqual({

View file

@ -4,10 +4,11 @@ import type {
ChecklistItemKey, ChecklistItemKey,
ImportCardInput, ImportCardInput,
OnboardingStatus, OnboardingStatus,
TransactionFilters,
} from "../shared/types"; } from "../shared/types";
/** /**
* Pure rules of the guided getting-started path (#396, #398). * Pure rules of the guided getting-started path (#396, #398, #399).
* *
* The provider (`useOnboardingChecklist`) feeds them its state; the banner, * The provider (`useOnboardingChecklist`) feeds them its state; the banner,
* the step cards and the pages that carry a card only read what they decide. * the step cards and the pages that carry a card only read what they decide.
@ -24,10 +25,10 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
/** /**
* Routes whose page carries a step card. Each card issue adds its page (#398: * Routes whose page carries a step card. Each card issue adds its page (#398:
* Import), and a page without a delivered card keeps the banner, so a partly * Import, #399: Transactions), and a page without a delivered card keeps the
* merged stack never removes any guidance. * banner, so a partly merged stack never removes any guidance.
*/ */
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import"]); export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import", "/transactions"]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = { const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"], "/import": ["importFolder", "source", "import"],
@ -332,6 +333,41 @@ export function importCardModel(
}; };
} }
/** The control of the Transactions page that the card points at. */
export type TransactionsCardTarget = "firstRowCategory";
export interface TransactionsCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: TransactionsCardTarget | null;
}
/**
* The Transactions page's card (#399), read from the page's filters and the
* checklist:
* - the "categorize by hand" step checked: "Done.", nothing to point at;
* - otherwise, the sentence that matches the list: the uncategorized
* transactions only (the guided entry, or the filter picked by hand), the
* filter named by its own label, or every transaction (an ordinary visit,
* or a guided entry that found none uncategorized); and the category of the
* first row shown.
*/
export function transactionsCardModel(
filters: Pick<TransactionFilters, "uncategorizedOnly">,
checklist: Checklist | null,
): TransactionsCardModel {
if (checked(checklist, "manualCategory")) return { text: null, done: true, target: null };
return {
text: filters.uncategorizedOnly
? {
key: "onboarding.card.transactions.uncategorized",
labels: { filter: "transactions.filters.uncategorized" },
}
: { key: "onboarding.card.transactions.all" },
done: false,
target: "firstRowCategory",
};
}
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */ /** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
export interface OnboardingTargetAttributes { export interface OnboardingTargetAttributes {
"data-onboarding-target"?: string; "data-onboarding-target"?: string;