docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407

Merged
maximus merged 11 commits from issue-401-onboarding-guide-qa into main 2026-10-09 01:11:03 +00:00
3 changed files with 50 additions and 6 deletions
Showing only changes of commit abc9076d23 - Show all commits

View file

@ -5,7 +5,7 @@
// subtotal-position preference is read from a stubbed localStorage. What is // 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 // pinned is where the one highlight lands: "Expand all" or this month's cell
// of the first editable row shown. // of the first editable row shown.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
@ -172,10 +172,24 @@ describe("BudgetTable — the getting-started card's target (#400)", () => {
expect(count(html, HIGHLIGHT)).toBe(1); expect(count(html, HIGHLIGHT)).toBe(1);
}); });
describe("in a timezone west of Greenwich", () => {
// Under TZ=UTC (the CI runner), 23:00 on December 31 is December both in
// local time and in UTC, so a month read in UTC would pass unnoticed. The
// zone is pinned as in useImportWizard.test.ts (#356).
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/Toronto";
});
afterAll(() => {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});
it("the cell follows the clock's month", () => { it("the cell follows the clock's month", () => {
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0)); vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER); expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
}); });
});
it("subtotals at the bottom: still the first editable row shown", () => { it("subtotals at the bottom: still the first editable row shown", () => {
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined }); vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });

View file

@ -43,8 +43,13 @@ export default function BudgetPage() {
// The getting-started card follows the grid (#400). Its target is // 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 // highlighted only while the card is on screen: a ring needs the card that
// explains it. // explains it. The grid's last report does not outlive the grid: the rows
const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist); // can land before the transaction count (both reads queue behind the DB
// lock), so the grid may report, then give way to the empty state.
const cardModel = budgetCardModel(
{ target: isEmptyProfile ? null : gridTarget },
onboarding.checklist,
);
const highlight = const highlight =
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname); cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);

View file

@ -183,4 +183,29 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
it("hands the grid the same callback at every render, so the grid does not report at every render", () => { 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); expect(render().report).toBe(render().report);
}); });
it("a profile without transactions: the grid's last report does not outlive the grid", () => {
// Steps 3 to 5 skipped: the budget step is next on a profile without any
// transaction. The rows can land before the count (both reads queue
// behind db.ts's lock): the grid mounts, reports "Expand all", then gives
// way to the empty state (review of #400).
const counters = { ...BUDGET_LEFT, imports: 0, manualCategorized: 0, transactions: 0 };
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
skipped: ["source", "import", "manualCategory"],
});
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = harness.budget as { state: BudgetState };
budget.state = { ...budget.state, totalInDb: null };
render().report("expandAll");
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
budget.state = { ...budget.state, totalInDb: 0 };
harness.cursor = 0;
const page = BudgetPage() as AnyElement;
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
const [card] = find(page, (el) => el.type === OnboardingStepCard);
expect(card.props.model).toEqual({ text: null, done: false, target: null });
});
}); });