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
14 changed files with 1212 additions and 79 deletions
Showing only changes of commit 2386486762 - Show all commits

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,52 +41,67 @@ 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 className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> {/* Outside the loading dim: the grid reloads after every save, right
<div className="flex items-center gap-3"> when the card says "Done.". */}
<h1 className="text-2xl font-bold">{t("budget.title")}</h1> <OnboardingStepCard model={cardModel} />
<PageHelp helpKey="budget" />
<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="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" />
</div>
<TemplateActions
templates={templates}
onApply={applyTemplate}
onApplyAllMonths={applyTemplateAllMonths}
onSave={saveTemplate}
onDelete={deleteTemplate}
disabled={isSaving}
/>
</div> </div>
<TemplateActions
templates={templates} {error && (
onApply={applyTemplate} <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
onApplyAllMonths={applyTemplateAllMonths} {error}
onSave={saveTemplate} </div>
onDelete={deleteTemplate} )}
disabled={isSaving}
/> {isEmptyProfile ? (
<EmptyState
icon={<Calculator size={40} />}
title={t("budget.empty.title")}
description={t("budget.empty.description")}
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
/>
) : (
<>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
<BudgetTable
rows={rows}
onUpdatePlanned={updatePlanned}
onSplitEvenly={splitEvenly}
onboardingTarget={highlight}
onOnboardingTargetChange={setGridTarget}
/>
</>
)}
</div> </div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error}
</div>
)}
{isEmptyProfile ? (
<EmptyState
icon={<Calculator size={40} />}
title={t("budget.empty.title")}
description={t("budget.empty.description")}
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
/>
) : (
<>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
<BudgetTable
rows={rows}
onUpdatePlanned={updatePlanned}
onSplitEvenly={splitEvenly}
/>
</>
)}
</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;