feat(onboarding): Budget page step card and its highlight #406

Merged
maximus merged 2 commits from issue-400-onboarding-budget-card into main 2026-10-09 01:10:55 +00:00
16 changed files with 1214 additions and 79 deletions

View file

@ -7,6 +7,7 @@
- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397). - Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397).
- Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398). - Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398).
- Une carte de démarrage sur la page Transactions, à la place du bandeau, tant que l'étape suivante du parcours est de classer une transaction à la main. Le lien de cette étape ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. La carte dit quoi faire et désigne la catégorie de la première transaction affichée. Choisir une catégorie coche l'étape : la carte annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite (#399). - Une carte de démarrage sur la page Transactions, à la place du bandeau, tant que l'étape suivante du parcours est de classer une transaction à la main. Le lien de cette étape ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. La carte dit quoi faire et désigne la catégorie de la première transaction affichée. Choisir une catégorie coche l'étape : la carte annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite (#399).
- Une carte de démarrage sur la page Budget, à la place du bandeau, tant que l'étape suivante du parcours est de saisir un premier budget. La grille du budget s'ouvre catégories repliées : la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir, et ne déplie jamais la grille à votre place. Elle rappelle qu'Entrée enregistre, et que Tab enregistre et passe au mois suivant. Enregistrer un montant coche l'étape : la carte annonce « Fait. » et termine le parcours, avec « Ouvrir le guide » et « Fermer » (#400).
### Modifié ### Modifié

View file

@ -7,6 +7,7 @@
- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397). - A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397).
- A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398). - A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398).
- A getting-started card on the Transactions page, in place of the banner, while the next step of the path is to categorize a transaction by hand. The link of that step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. The card says what to do and points at the category of the first transaction shown. Choosing a category ticks the step: the card says "Done." and offers "Enter a first budget", or the end of the path in the Free edition (#399). - A getting-started card on the Transactions page, in place of the banner, while the next step of the path is to categorize a transaction by hand. The link of that step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. The card says what to do and points at the category of the first transaction shown. Choosing a category ticks the step: the card says "Done." and offers "Enter a first budget", or the end of the path in the Free edition (#399).
- A getting-started card on the Budget page, in place of the banner, while the next step of the path is to enter a first budget. The budget grid opens with its categories folded: the card first points at "Expand all", then at the current month's cell of the first category you can fill in, and never unfolds the grid for you. It reminds you that Enter saves, and that Tab saves and moves to the next month. Saving an amount ticks the step: the card says "Done." and ends the path, with "Open the guide" and "Close" (#400).
### Changed ### Changed

View file

@ -0,0 +1,205 @@
// BudgetTable and the getting-started card's target (#400). No jsdom:
// react-dom/server renders in vitest's node environment. The collapse hook is
// replaced by the real pure helpers over a state the test sets (the real hook
// hydrates in an effect, which a static render never runs), and the
// subtotal-position preference is read from a stubbed localStorage. What is
// pinned is where the one highlight lands: "Expand all" or this month's cell
// of the first editable row shown.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
const harness = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
toggle: () => undefined,
expandAll: () => undefined,
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
},
};
});
import BudgetTable, { BUDGET_CELL_TARGET, BUDGET_EXPAND_ALL_TARGET } from "./BudgetTable";
const leaf = (
category_id: number,
category_name: string,
category_type: BudgetYearRow["category_type"],
parent_id: number | null = null,
): BudgetYearRow => ({
category_id,
category_name,
category_color: "#9ca3af",
category_type,
parent_id,
is_parent: false,
depth: parent_id === null ? 0 : 1,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
});
const group = (
category_id: number,
category_name: string,
category_type: BudgetYearRow["category_type"],
): BudgetYearRow => ({ ...leaf(category_id, category_name, category_type), is_parent: true });
/** Two groups, in the order `buildBudgetYearRows` emits them: income first. */
const GROUPED: BudgetYearRow[] = [
group(1, "Revenus", "income"),
leaf(2, "Salaire", "income", 1),
group(10, "Logement", "expense"),
leaf(11, "Loyer", "expense", 10),
leaf(12, "Électricité", "expense", 10),
];
/** A category without sub-category stays on screen while the groups are collapsed. */
const WITH_STANDALONE: BudgetYearRow[] = [...GROUPED, leaf(30, "Divers", "expense")];
/** No group at all. */
const FLAT: BudgetYearRow[] = [leaf(30, "Divers", "expense"), leaf(31, "Cadeaux", "expense")];
const MARCH = 2;
const DECEMBER = 11;
const render = (rows: BudgetYearRow[], onboardingTarget?: boolean) =>
renderToStaticMarkup(
<BudgetTable
rows={rows}
onUpdatePlanned={() => undefined}
onSplitEvenly={() => undefined}
onboardingTarget={onboardingTarget}
/>,
);
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
/** The one highlighted control: its target key and its inner markup. */
function highlighted(html: string): { target: string; inner: string } {
expect(count(html, HIGHLIGHT)).toBe(1);
const match = html.match(/<button [^>]*data-onboarding-target="([^"]+)" data-onboarding-highlight="true"[^>]*>(.*?)<\/button>/);
expect(match).not.toBeNull();
return { target: match![1], inner: match![2] };
}
/** The markup of the row named `name`, from its `<tr` to its `</tr>`. */
function rowOf(html: string, name: string): string {
const at = html.indexOf(`>${name}<`);
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<tr", at), html.indexOf("</tr>", at));
}
/** The month (0-11) of the highlighted cell in a row, -1 for none: name, last year and annual come first. */
function ringedMonth(row: string): number {
const cell = row
.split("<td")
.slice(1)
.findIndex((td) => td.includes(HIGHLIGHT));
return cell === -1 ? -1 : cell - 3;
}
beforeEach(() => {
harness.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, MARCH, 15));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("BudgetTable — the getting-started card's target (#400)", () => {
it("groups collapsed: 'Expand all' is the one target, no cell", () => {
const html = render(GROUPED, true);
const target = highlighted(html);
expect(target.target).toBe(BUDGET_EXPAND_ALL_TARGET);
expect(target.inner).toContain("t(reports.collapse.expandAll)");
expect(html).not.toContain(BUDGET_CELL_TARGET);
});
it("a group open: this month's cell of its first editable row is the one target", () => {
harness.expanded = new Set(["p:10"]);
const html = render(GROUPED, true);
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
expect(ringedMonth(rowOf(html, "Électricité"))).toBe(-1);
// The group's subtotal row has no button to point at.
expect(ringedMonth(rowOf(html, "Logement"))).toBe(-1);
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
});
it("every group open: the first editable row of the grid, income first", () => {
harness.expanded = new Set(["p:1", "p:10"]);
const html = render(GROUPED, true);
expect(ringedMonth(rowOf(html, "Salaire"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
it("a category without sub-category shown beside collapsed groups: its cell, 'Expand all' stays plain", () => {
const html = render(WITH_STANDALONE, true);
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
expect(html).toContain("t(reports.collapse.expandAll)");
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
});
it("no groups: no 'Expand all' at all, the first row's cell", () => {
const html = render(FLAT, true);
expect(html).not.toContain("t(reports.collapse.expandAll)");
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
it("the cell follows the clock's month", () => {
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
});
it("subtotals at the bottom: still the first editable row shown", () => {
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
harness.expanded = new Set(["p:10"]);
const html = render(GROUPED, true);
expect(html).toContain("t(reports.subtotalsOnBottom)");
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
it("without `onboardingTarget`, nothing is highlighted", () => {
for (const expanded of [[], ["p:10"]]) {
harness.expanded = new Set(expanded);
for (const onboardingTarget of [false, undefined]) {
const html = render(GROUPED, onboardingTarget);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).not.toContain("data-onboarding-target");
}
}
});
it("no row: the grid's 'no category' box, nothing highlighted", () => {
const html = render([], true);
expect(html).toContain("t(budget.noCategories)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
});

View file

@ -1,11 +1,13 @@
import { useState, useRef, useEffect, Fragment } from "react"; import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetOnboardingTargetKind, BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults"; import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -37,13 +39,35 @@ const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
const BUDGET_EXPANDED_KEY = "budget-grid-expanded"; const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
/** `data-onboarding-target` of the highlighted month cell. */
export const BUDGET_CELL_TARGET = "budget-cell";
/** `data-onboarding-target` of the highlighted "Expand all" button. */
export const BUDGET_EXPAND_ALL_TARGET = "budget-expand-all";
interface BudgetTableProps { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
onSplitEvenly: (categoryId: number, annualAmount: number) => void; onSplitEvenly: (categoryId: number, annualAmount: number) => void;
/**
* Highlight the control the getting-started card points at (#400): this
* month's cell of the first editable row shown, or "Expand all" while none
* is shown (`budgetOnboardingTarget`). One target at a time.
*/
onboardingTarget?: boolean;
/**
* Told which control that is, at mount and whenever it changes: the
* collapse state lives here, and the card's sentence depends on it.
*/
onOnboardingTargetChange?: (kind: BudgetOnboardingTargetKind | null) => void;
} }
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) { export default function BudgetTable({
rows,
onUpdatePlanned,
onSplitEvenly,
onboardingTarget,
onOnboardingTargetChange,
}: BudgetTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null); const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null); const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
@ -177,6 +201,30 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
// The rows each section shows, in the order it shows them: collapse-filtered
// (issue #289), then subtotals moved to the chosen side.
const shownByType: Record<string, BudgetYearRow[]> = {};
for (const type of typeOrder) {
shownByType[type] = reorderRows(groups.visible(grouped[type] ?? []), subtotalsOnTop);
}
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
// The control the getting-started card points at (#400, D12): this month's
// cell of the first editable row shown, else "Expand all". Its kind goes up
// to the page, which picks the card's sentence; the grid stays as the user
// left it (the card never unfolds it).
const target = budgetOnboardingTarget(
typeOrder.flatMap((type) => shownByType[type]),
hasGroups,
new Date().getMonth(),
);
const targetKind = target?.kind ?? null;
useEffect(() => {
onOnboardingTargetChange?.(targetKind);
}, [targetKind, onOnboardingTargetChange]);
const targetCell = onboardingTarget && target?.kind === "cell" ? target : null;
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
@ -321,6 +369,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
onClick={() => handleStartEdit(row.category_id, mIdx, val)} onClick={() => handleStartEdit(row.category_id, mIdx, val)}
title={t("budget.clickToEdit")} title={t("budget.clickToEdit")}
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5" className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
{...onboardingTargetAttributes(
BUDGET_CELL_TARGET,
targetCell !== null &&
targetCell.categoryId === row.category_id &&
targetCell.monthIdx === mIdx,
)}
> >
{formatSigned(val * sign)} {formatSigned(val * sign)}
</button> </button>
@ -352,7 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{t(typeLabelKeys[type])} {t(typeLabelKeys[type])}
</td> </td>
</tr> </tr>
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))} {shownByType[type].map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]"> <tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold"> <td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])} {t(typeTotalKeys[type])}
@ -395,9 +449,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
); );
}; };
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return ( return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
@ -406,6 +457,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
type="button" type="button"
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))} onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
{...onboardingTargetAttributes(
BUDGET_EXPAND_ALL_TARGET,
onboardingTarget && target?.kind === "expandAll",
)}
> >
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />} {allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")} {allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}

View file

@ -0,0 +1,174 @@
// BudgetTable — how the grid tells the page its target (#400).
//
// renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's `useState`, `useRef` and `useEffect` are stubbed (technique of
// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the
// grid runs as a plain function, its state and refs survive from one call to
// the next as between two renders of a mounted component, and `commit()` runs
// the effects as React does, an effect only when one of its dependencies
// changed since its last run. The collapse hook is replaced by the real pure
// helpers over a state the test sets.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
effects: [] as Effect[],
/** Dependencies of each effect at its last run. */
ran: new Map<number, readonly unknown[] | undefined>(),
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
const slot = (init: () => unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) harness.slots[index] = init();
return index;
};
return {
...actual,
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
useRef: (init: unknown) => harness.slots[slot(() => ({ current: init }))],
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
toggle: () => undefined,
expandAll: () => undefined,
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
},
};
});
import BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types";
const leaf = (category_id: number, parent_id: number | null = null): BudgetYearRow => ({
category_id,
category_name: `Category ${category_id}`,
category_color: "#9ca3af",
category_type: "expense",
parent_id,
is_parent: false,
depth: parent_id === null ? 0 : 1,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
});
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
/** One render of the mounted grid: its hooks are called in the same order each time. */
function render(
rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean,
) {
harness.cursor = 0;
harness.effects = [];
BudgetTable({
rows,
onUpdatePlanned: () => undefined,
onSplitEvenly: () => undefined,
onboardingTarget,
onOnboardingTargetChange: onChange,
});
}
/** After a render: run each effect whose dependencies changed since its last run, as React does. */
function commit() {
harness.effects.forEach((effect, index) => {
const previous = harness.ran.get(index);
const changed =
!harness.ran.has(index) ||
effect.deps === undefined ||
previous === undefined ||
effect.deps.some((dep, k) => !Object.is(dep, previous[k]));
harness.ran.set(index, effect.deps);
if (changed) effect.fn();
});
}
beforeEach(() => {
harness.slots = [];
harness.setters = [];
harness.effects = [];
harness.ran = new Map();
harness.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("BudgetTable — the target told to the page (#400)", () => {
it("tells it once mounted, then only when it changes", () => {
const onChange = vi.fn();
render(GROUPED, onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"]]);
// Another render with nothing changed: not told again.
render(GROUPED, onChange, true);
commit();
expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows.
harness.expanded = new Set(["p:10"]);
render(GROUPED, onChange, true);
commit();
render(GROUPED, onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"]]);
// The rows are gone: nothing to point at.
render([], onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"], [null]]);
});
it("tells it before any highlight is asked: the page needs it for the card's sentence", () => {
const onChange = vi.fn();
render([leaf(30)], onChange);
commit();
expect(onChange.mock.calls).toEqual([["cell"]]);
});
it("tells nothing during a render", () => {
const onChange = vi.fn();
render(GROUPED, onChange, true);
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
import type { BudgetYearRow } from "../../shared/types";
/** A grid row; a leaf at the top level unless `overrides` says otherwise. */
const row = (category_id: number, overrides: Partial<BudgetYearRow> = {}): BudgetYearRow => ({
category_id,
category_name: `Category ${category_id}`,
category_color: "#9ca3af",
category_type: "expense",
parent_id: null,
is_parent: false,
depth: 0,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
...overrides,
});
/** A group's subtotal row: read-only. */
const group = (category_id: number, overrides: Partial<BudgetYearRow> = {}) =>
row(category_id, { is_parent: true, ...overrides });
/** A leaf under `parent_id`. */
const child = (category_id: number, parent_id: number, depth = 1) =>
row(category_id, { parent_id, depth });
const OCTOBER = 9;
describe("budgetOnboardingTarget (#400, D12)", () => {
it("groups collapsed: 'Expand all', the editable rows being folded away", () => {
// Collapsed, the grid shows its top-level subtotals only.
expect(budgetOnboardingTarget([group(10), group(20)], true, OCTOBER)).toEqual({
kind: "expandAll",
});
// A group open on a sub-group still folded: still no editable row shown.
expect(
budgetOnboardingTarget([group(10), group(11, { parent_id: 10, depth: 1 })], true, OCTOBER),
).toEqual({ kind: "expandAll" });
});
it("the first leaf shown: its cell in the given month, past the subtotal rows above it", () => {
expect(budgetOnboardingTarget([group(10), child(13, 10), child(14, 10)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 13,
monthIdx: OCTOBER,
});
// A sub-group's subtotal before the leaves is skipped too.
expect(
budgetOnboardingTarget(
[group(10), group(11, { parent_id: 10, depth: 1 }), child(15, 11, 2)],
true,
OCTOBER,
),
).toEqual({ kind: "cell", categoryId: 15, monthIdx: OCTOBER });
});
it("subtotals at the bottom: the first leaf, not the subtotal under it", () => {
expect(budgetOnboardingTarget([child(13, 10), child(14, 10), group(10)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 13,
monthIdx: OCTOBER,
});
});
it("a '(direct)' leaf is a leaf: its cell, though it shares its group's category id", () => {
expect(
budgetOnboardingTarget([group(10), row(10, { parent_id: 10, depth: 1 }), child(13, 10)], true, 0),
).toEqual({ kind: "cell", categoryId: 10, monthIdx: 0 });
});
it("a leaf shown while the groups are collapsed (a category without sub-category): already a cell", () => {
expect(budgetOnboardingTarget([group(10), row(30), group(20)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx: OCTOBER,
});
});
it("without groups: the first row's cell", () => {
expect(budgetOnboardingTarget([row(30), row(31)], false, OCTOBER)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx: OCTOBER,
});
});
it("no row: nothing to point at", () => {
expect(budgetOnboardingTarget([], false, OCTOBER)).toBeNull();
expect(budgetOnboardingTarget([], false, 0)).toBeNull();
});
it("keeps the month it is given, January to December", () => {
for (const monthIdx of [0, 5, 11]) {
expect(budgetOnboardingTarget([row(30)], false, monthIdx)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx,
});
}
});
});

View file

@ -0,0 +1,33 @@
import type { BudgetYearRow } from "../../shared/types";
/**
* The control of the budget grid that the getting-started card points at
* (#400): the month cell of an editable row, or "Expand all".
*/
export type BudgetOnboardingTarget =
| { kind: "cell"; categoryId: number; monthIdx: number }
| { kind: "expandAll" };
/**
* Which control of the budget grid the Budget step card points at (#400, D12).
*
* `visibleRows` are the rows the grid shows, in the order it shows them. A
* subtotal row is read-only and a row folded under a collapsed group is not on
* screen, so the target is:
* - the `monthIdx` cell of the first leaf row shown;
* - otherwise "Expand all" when the grid has groups: its editable rows are
* folded away, and the card never unfolds them for the user;
* - otherwise null: no row at all, nothing to point at.
*
* Pure (no React, no storage), like `budgetTableResults.ts`, so the rule is
* tested without rendering the grid and its collapse hook.
*/
export function budgetOnboardingTarget(
visibleRows: readonly BudgetYearRow[],
hasGroups: boolean,
monthIdx: number,
): BudgetOnboardingTarget | null {
const leaf = visibleRows.find((row) => !row.is_parent);
if (leaf) return { kind: "cell", categoryId: leaf.category_id, monthIdx };
return hasGroups ? { kind: "expandAll" } : null;
}

View file

@ -70,11 +70,16 @@ const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState); ].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */ /** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue { function pathAt(
pathname: string,
state: OnboardingChecklistState,
budgetReady = true,
budgetAllowed = true,
): OnboardingValue {
const checklist = state.counters const checklist = state.counters
? computeChecklist(state.counters, { ? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet, importFolderSet: state.importFolderSet,
budgetAllowed: true, budgetAllowed,
skipped: state.skipped, skipped: state.skipped,
}) })
: null; : null;
@ -150,18 +155,6 @@ describe("AppShell — the getting-started banner (#397)", () => {
} }
}); });
it.each([["/budget", BUDGET_LEFT]] as const)(
"keeps the banner on %s, the page of the next step, while no step card is delivered",
(pathname, snapshot) => {
expect(CARD_PAGES.has(pathname)).toBe(false);
const value = pathAt(pathname, loaded(snapshot));
expect(value.nextStep?.to).toBe(pathname);
const html = renderWith(pathname, value);
expect(html).toContain(BANNER);
expect(html).toContain(`href="${pathname}"`);
},
);
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => { it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
expect(CARD_PAGES.has("/import")).toBe(true); expect(CARD_PAGES.has("/import")).toBe(true);
const value = pathAt("/import", loaded(NEW_PROFILE)); const value = pathAt("/import", loaded(NEW_PROFILE));
@ -184,6 +177,28 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"'); expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"');
}); });
it("gives /budget, the page of the next step, its step card instead of the banner (#400)", () => {
expect(CARD_PAGES.has("/budget")).toBe(true);
const value = pathAt("/budget", loaded(BUDGET_LEFT));
expect(value.nextStep?.to).toBe("/budget");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/budget");
expect(renderWith("/budget", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /budget.
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
});
it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => {
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
expect(value.checklist?.total).toBe(5);
expect(value.nextStep).toBeNull();
expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete");
const html = renderWith("/budget", value);
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
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

@ -93,6 +93,10 @@
"transactions": { "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.", "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." "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."
},
"budget": {
"collapsed": "Click {{expand}} to show your categories, then click a month and enter an amount. Enter saves; Tab saves and moves to the next month.",
"flat": "Click a month and enter an amount. Enter saves; Tab saves and moves to the next month."
} }
}, },
"state": { "state": {

View file

@ -93,6 +93,10 @@
"transactions": { "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.", "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." "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."
},
"budget": {
"collapsed": "Cliquez {{expand}} pour afficher vos catégories, puis cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.",
"flat": "Cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant."
} }
}, },
"state": { "state": {

View file

@ -0,0 +1,194 @@
// BudgetPage and its getting-started card (#400). No jsdom: react-dom/server
// renders in vitest's node environment. The budget is a fake `useBudget()`
// over its real initial state, the path a fake `useOnboarding()` built from the
// real checklist, and the grid is the real BudgetTable. A static render runs
// no effect, so the grid never reports its target here (the report and what
// the page does with it are in BudgetPage.wiring.test.ts). What is pinned is
// where the card sits and what it says before and after the step is checked.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
// The budget, collapse and preference 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(() => ({ budget: null as unknown, onboarding: null as unknown }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => harness.budget,
}));
import BudgetPage from "./BudgetPage";
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { BudgetYearRow, OnboardingCounters } from "../shared/types";
/** Steps 2 to 5 done: the next step is the budget. */
const BUDGET_LEFT: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 0,
transactions: 41,
};
/** A first amount saved: every step done. */
const BUDGETED: OnboardingCounters = { ...BUDGET_LEFT, budgetEntries: 1 };
const ROWS: BudgetYearRow[] = [
{
category_id: 10,
category_name: "Logement",
category_color: "#9ca3af",
category_type: "expense",
parent_id: null,
is_parent: true,
depth: 0,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
},
{
category_id: 11,
category_name: "Loyer",
category_color: "#9ca3af",
category_type: "expense",
parent_id: 10,
is_parent: false,
depth: 1,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
},
];
/** The budget after its first read, with `overrides`. */
function budget(overrides: Partial<BudgetState> = {}) {
const state: BudgetState = { ...initialBudgetState(), rows: ROWS, totalInDb: 41, ...overrides };
const noop = vi.fn();
return new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : noop),
});
}
/** The path latched on /budget (card mode), as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/budget",
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 render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/budget"]}>
<BudgetPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const DIM = 'class="opacity-50 pointer-events-none"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
/**
* The card's sentence: the paragraph right after the card's own title, null
* without one (the empty state below has a title and a paragraph too).
*/
function sentence(html: string): string | null {
const title = html.indexOf("</h2>", html.indexOf(CARD));
return html.slice(title).match(/^<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
}
beforeEach(() => {
harness.budget = budget();
harness.onboarding = path(BUDGET_LEFT);
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("BudgetPage — the step card (#400)", () => {
it("renders the card first, before the page's heading, at its place in the path", () => {
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=6])");
expect(html).toContain("t(onboarding.checklist.items.budget)");
expect(html).toContain("t(onboarding.card.skip)");
// The grid is there, below the card.
expect(html.indexOf("Logement")).toBeGreaterThan(html.indexOf(CARD));
});
it("before the grid reports its target: the title and 'Skip this step', no sentence, no ring", () => {
const html = render();
expect(sentence(html)).toBeNull();
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("keeps the card out of the loading dim, which still covers the page", () => {
harness.budget = budget({ isLoading: true });
const html = render();
expect(count(html, DIM)).toBe(1);
expect(html.indexOf("t(onboarding.card.skip)")).toBeLessThan(html.indexOf(DIM));
expect(html.indexOf(DIM)).toBeLessThan(html.indexOf("<h1"));
});
it("a first amount saved: the card stays, says 'Done.' and ends the path", () => {
harness.onboarding = path(BUDGETED);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).toContain("t(onboarding.banner.openGuide)");
expect(html).not.toContain("t(onboarding.card.skip)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("a profile without transactions: the card above the 'import first' state", () => {
harness.budget = budget({ rows: [], totalInDb: 0 });
const html = render();
expect(html).toContain("t(budget.empty.title)");
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("t(budget.empty.title)"));
expect(html).toContain("t(onboarding.card.skip)");
expect(sentence(html)).toBeNull();
});
it("no card when the path is not in card mode on this page", () => {
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.onboarding = path(BUDGET_LEFT, overrides);
const html = render();
expect(html).not.toContain(CARD);
expect(html).toContain("Logement");
}
});
});

View file

@ -1,16 +1,24 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Calculator } from "lucide-react"; import { Calculator } 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 FilterPanel from "../components/reports/FilterPanel"; import FilterPanel from "../components/reports/FilterPanel";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import { budgetCardModel, showsStepCard } from "../utils/onboardingPath";
import { useBudget, showsEmptyState } from "../hooks/useBudget"; import { useBudget, showsEmptyState } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod"; import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator"; import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions"; import TemplateActions from "../components/budget/TemplateActions";
import type { BudgetOnboardingTargetKind } from "../shared/types";
export default function BudgetPage() { export default function BudgetPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname } = useLocation();
const onboarding = useOnboarding();
const { accountIds, setAccountIds } = useReportsPeriod(); const { accountIds, setAccountIds } = useReportsPeriod();
const { const {
state, state,
@ -22,6 +30,9 @@ export default function BudgetPage() {
applyTemplateAllMonths, applyTemplateAllMonths,
deleteTemplate, deleteTemplate,
} = useBudget(); } = useBudget();
// What the grid points the getting-started card at (#400). The collapse
// state lives in BudgetTable, which reports its target here.
const [gridTarget, setGridTarget] = useState<BudgetOnboardingTargetKind | null>(null);
const { year, rows, templates, accounts, isLoading, isSaving, error } = state; const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
@ -30,7 +41,19 @@ export default function BudgetPage() {
// actions stay. // actions stay.
const isEmptyProfile = showsEmptyState(state); const isEmptyProfile = showsEmptyState(state);
// The getting-started card follows the grid (#400). Its target is
// highlighted only while the card is on screen: a ring needs the card that
// explains it.
const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist);
const highlight =
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
return ( return (
<div>
{/* Outside the loading dim: the grid reloads after every save, right
when the card says "Done.". */}
<OnboardingStepCard model={cardModel} />
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <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"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -73,9 +96,12 @@ export default function BudgetPage() {
rows={rows} rows={rows}
onUpdatePlanned={updatePlanned} onUpdatePlanned={updatePlanned}
onSplitEvenly={splitEvenly} onSplitEvenly={splitEvenly}
onboardingTarget={highlight}
onOnboardingTargetChange={setGridTarget}
/> />
</> </>
)} )}
</div> </div>
</div>
); );
} }

View file

@ -0,0 +1,186 @@
// BudgetPage — what the page does with the grid's target (#400).
//
// A static render runs no effect, so the grid never reports there. React's
// `useState` is stubbed (technique of AppShell.slot.test.tsx): the page runs
// as a plain function, its state survives from one call to the next as
// between two renders of a mounted component, and its setters keep their
// identity as React's do. The test plays the grid by calling the callback the
// page hands it, then reads what the page hands the card and the grid.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
pathname: "/budget",
onboarding: null as unknown,
budget: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
// The page's modules pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
}));
vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => harness.budget,
}));
import BudgetPage from "./BudgetPage";
import BudgetTable from "../components/budget/BudgetTable";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
/** Steps 2 to 5 done: the next step is the budget. */
const BUDGET_LEFT: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 0,
transactions: 41,
};
/** The path latched on /budget (card mode), as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/budget",
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,
};
}
/** One render of the mounted page: its hooks are read in the same order each time. */
function render() {
harness.cursor = 0;
const page = BudgetPage() as AnyElement;
const [card] = find(page, (el) => el.type === OnboardingStepCard);
const [grid] = find(page, (el) => el.type === BudgetTable);
return {
page,
model: card.props.model as BudgetCardModel,
highlight: grid.props.onboardingTarget as boolean,
report: grid.props.onOnboardingTargetChange as (kind: BudgetOnboardingTargetKind | null) => void,
};
}
beforeEach(() => {
harness.slots = [];
harness.setters = [];
harness.pathname = "/budget";
harness.onboarding = path(BUDGET_LEFT);
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
harness.budget = new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
});
});
describe("BudgetPage — the grid's target and the card (#400)", () => {
it("renders the card as the page's first child, ahead of its heading", () => {
const { page } = render();
const children = page.props.children as ReactNode[];
expect((children[0] as AnyElement).type).toBe(OnboardingStepCard);
expect(find(children[0], (el) => el.type === "h1")).toHaveLength(0);
expect(find(children.slice(1), (el) => el.type === "h1")).toHaveLength(1);
});
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
const checklist = (harness.onboarding as OnboardingValue).checklist;
let view = render();
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
expect(view.highlight).toBe(false);
view.report("expandAll");
view = render();
expect(view.model.text?.key).toBe("onboarding.card.budget.collapsed");
expect(view.highlight).toBe(true);
view.report("cell");
view = render();
expect(view.model.text?.key).toBe("onboarding.card.budget.flat");
expect(view.highlight).toBe(true);
view.report(null);
view = render();
expect(view.model.text).toBeNull();
expect(view.highlight).toBe(false);
});
it("asks for no ring while the card is not on screen", () => {
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "complete" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.slots = [];
harness.setters = [];
harness.onboarding = path(BUDGET_LEFT, overrides);
render().report("cell");
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
}
});
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
harness.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
render().report("cell");
const view = render();
expect(view.model).toEqual({ text: null, done: true, target: null });
expect(view.highlight).toBe(false);
});
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
expect(render().report).toBe(render().report);
});
});

View file

@ -1021,3 +1021,19 @@ export interface ImportCardInput {
/** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */ /** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */
importedCount: number | null; importedCount: number | null;
} }
/**
* The control of the budget grid that the Budget step card points at (#400):
* a month cell of an editable row, or "Expand all" while none is shown.
*/
export type BudgetOnboardingTargetKind = "cell" | "expandAll";
/**
* What the Budget page's step card reads of the grid (#400). The collapse
* state lives in `BudgetTable`, which reports its target to the page;
* `budgetCardModel` turns it into the card.
*/
export interface BudgetCardInput {
/** The grid's target; null without one (no grid on the page, no row) or before the grid says. */
target: BudgetOnboardingTargetKind | null;
}

View file

@ -4,6 +4,7 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist";
import { import {
CARD_PAGES, CARD_PAGES,
bannerMode, bannerMode,
budgetCardModel,
cardNext, cardNext,
cardStep, cardStep,
importCardModel, importCardModel,
@ -16,6 +17,7 @@ import {
stepsOfPage, stepsOfPage,
transactionsCardModel, transactionsCardModel,
type BannerModeInput, type BannerModeInput,
type BudgetCardModel,
type ImportCardModel, type ImportCardModel,
type StepCardText, type StepCardText,
type TransactionsCardModel, type TransactionsCardModel,
@ -61,6 +63,17 @@ const step = (key: ChecklistItemKey): ChecklistItem =>
const ALL_CARD_PAGES: ReadonlySet<string> = new Set(["/import", "/transactions", "/budget"]); const ALL_CARD_PAGES: ReadonlySet<string> = new Set(["/import", "/transactions", "/budget"]);
/** A sentence of a 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 })]),
),
});
describe("nextStep (#396)", () => { describe("nextStep (#396)", () => {
it("is the folder step on a new profile", () => { it("is the folder step on a new profile", () => {
expect(nextStep(checklistOf(zero))?.key).toBe("importFolder"); expect(nextStep(checklistOf(zero))?.key).toBe("importFolder");
@ -346,7 +359,7 @@ describe("stepNavState (#397)", () => {
}); });
}); });
describe("CARD_PAGES (#398, #399)", () => { describe("CARD_PAGES (#398, #399, #400)", () => {
it("carries the Import page", () => { it("carries the Import page", () => {
expect(CARD_PAGES.has("/import")).toBe(true); expect(CARD_PAGES.has("/import")).toBe(true);
}); });
@ -354,6 +367,10 @@ describe("CARD_PAGES (#398, #399)", () => {
it("carries the Transactions page", () => { it("carries the Transactions page", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true); expect(CARD_PAGES.has("/transactions")).toBe(true);
}); });
it("carries the Budget page", () => {
expect(CARD_PAGES.has("/budget")).toBe(true);
});
}); });
describe("showsStepCard (#398)", () => { describe("showsStepCard (#398)", () => {
@ -594,17 +611,6 @@ describe("transactionsCardModel (#399)", () => {
target: "firstRowCategory", 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", () => { it("only the uncategorized transactions listed: says so, the filter named by its own label", () => {
expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED); expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED);
}); });
@ -655,6 +661,76 @@ describe("transactionsCardModel (#399)", () => {
}); });
}); });
describe("budgetCardModel (#400)", () => {
/** Steps 2 to 5 done: the next step is the budget. */
const budgetLeft = checklistOf(upToManual, { importFolderSet: true });
const COLLAPSED: BudgetCardModel = {
text: {
key: "onboarding.card.budget.collapsed",
labels: { expand: "reports.collapse.expandAll" },
},
done: false,
target: "expandAll",
};
const FLAT: BudgetCardModel = {
text: { key: "onboarding.card.budget.flat" },
done: false,
target: "cell",
};
const DONE: BudgetCardModel = { text: null, done: true, target: null };
it("'Expand all' pointed at: unfold the grid first, the button named by its own label", () => {
expect(budgetCardModel({ target: "expandAll" }, budgetLeft)).toEqual(COLLAPSED);
});
it("a month cell pointed at: the sentence for a grid whose editable rows show", () => {
expect(budgetCardModel({ target: "cell" }, budgetLeft)).toEqual(FLAT);
});
it("no target (no grid on the page, no row, or not said yet): no sentence and no ring", () => {
expect(budgetCardModel({ target: null }, budgetLeft)).toEqual({
text: null,
done: false,
target: null,
});
});
it("follows the grid before the checklist is read", () => {
expect(budgetCardModel({ target: "expandAll" }, null)).toEqual(COLLAPSED);
expect(budgetCardModel({ target: "cell" }, null)).toEqual(FLAT);
});
it("the budget step checked: 'Done.', nothing to point at, whatever the grid shows", () => {
const budgeted = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
for (const target of ["expandAll", "cell", null] as const) {
expect(budgetCardModel({ target }, budgeted), String(target)).toEqual(DONE);
}
});
it("a skipped step is not a checked one", () => {
const skipped = checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] });
expect(budgetCardModel({ target: "cell" }, skipped)).toEqual(FLAT);
});
it("reads the two sentences in French, the button named by its label", () => {
expect(render(COLLAPSED.text, "fr")).toBe(
"Cliquez Tout déplier pour afficher vos catégories, puis cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.",
);
expect(render(FLAT.text, "fr")).toBe(
"Cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.",
);
});
it("reads the two sentences in English, the button named by its label", () => {
expect(render(COLLAPSED.text, "en")).toBe(
"Click Expand all to show your categories, then click a month and enter an amount. Enter saves; Tab saves and moves to the next month.",
);
expect(render(FLAT.text, "en")).toBe(
"Click a month and enter an amount. Enter saves; Tab saves and moves to the next month.",
);
});
});
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

@ -1,5 +1,7 @@
import type { Checklist } from "./onboardingChecklist"; import type { Checklist } from "./onboardingChecklist";
import type { import type {
BudgetCardInput,
BudgetOnboardingTargetKind,
ChecklistItem, ChecklistItem,
ChecklistItemKey, ChecklistItemKey,
ImportCardInput, ImportCardInput,
@ -8,7 +10,7 @@ import type {
} from "../shared/types"; } from "../shared/types";
/** /**
* Pure rules of the guided getting-started path (#396, #398, #399). * Pure rules of the guided getting-started path (#396, #398, #399, #400).
* *
* 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.
@ -25,10 +27,14 @@ 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, #399: Transactions), and a page without a delivered card keeps the * Import, #399: Transactions, #400: Budget), and a page without a delivered
* banner, so a partly merged stack never removes any guidance. * card keeps the banner, so a partly merged stack never removes any guidance.
*/ */
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import", "/transactions"]); export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
"/import",
"/transactions",
"/budget",
]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = { const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"], "/import": ["importFolder", "source", "import"],
@ -368,6 +374,45 @@ export function transactionsCardModel(
}; };
} }
export interface BudgetCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: BudgetOnboardingTargetKind | null;
}
/**
* The Budget page's card (#400), read from the grid's target and the
* checklist:
* - the budget step checked: "Done.", nothing to point at; an entry of 0
* counts, as it always has (D13);
* - "Expand all" pointed at: the sentence that starts by unfolding the
* grid, the button named by its own label;
* - a month cell pointed at: the sentence for a grid whose editable rows
* show;
* - no target (the grid is not on the page, has no row, or has not said yet):
* no sentence and no ring; the title and "Skip this step" stay.
*/
export function budgetCardModel(
input: BudgetCardInput,
checklist: Checklist | null,
): BudgetCardModel {
if (checked(checklist, "budget")) return { text: null, done: true, target: null };
switch (input.target) {
case "expandAll":
return {
text: {
key: "onboarding.card.budget.collapsed",
labels: { expand: "reports.collapse.expandAll" },
},
done: false,
target: "expandAll",
};
case "cell":
return { text: { key: "onboarding.card.budget.flat" }, done: false, target: "cell" };
default:
return { text: null, done: false, target: null };
}
}
/** 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;