From 64a07f359f039da9446232259b14c89aa3ba26f4 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 13 Oct 2026 21:32:20 -0400 Subject: [PATCH 1/3] feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization Two visit steps join the getting-started path: "Browse categories and keywords" (5th) and "Browse the reports" (8th). Nothing in the database counts them: they are done by "Got it" on their card, stored as onboarding_acknowledged_ in user_preferences (no migration). - computeChecklist: eight rows; the visit rows are locked until the profile's visit steps are initialized, so a finished path does not flash a "Next step" before they are marked done. - initializeVisitSteps: once per profile, after the first successful load with the Budget entitlement known; acknowledges both steps when every other step is done or skipped, then sets the flag. No transaction (D16): idempotent writes, the flag last. - Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps the card latch, so the card says "Done." and offers the next step. - StepCard: "Got it" before "Skip this step" when onAcknowledge is given; OnboardingStepCard passes it for a visit step. - Banner counter, card rank and end of path wait for budgetReady && visitStepsInitialized; the list shows the visit rows "pending" until then. Co-Authored-By: Claude Opus 5.5 --- .../DashboardEmptyState.render.test.tsx | 20 +- .../dashboard/DashboardEmptyState.tsx | 4 + .../dashboard/OnboardingChecklist.test.tsx | 82 +++++- .../dashboard/OnboardingChecklist.tsx | 22 +- .../layout/AppShell.render.test.tsx | 28 +- src/components/layout/AppShell.slot.test.tsx | 19 +- src/components/layout/AppShell.tsx | 1 + .../OnboardingBanner.render.test.tsx | 40 ++- .../onboarding/OnboardingBanner.tsx | 11 +- .../onboarding/OnboardingStepCard.test.tsx | 60 ++++- .../onboarding/OnboardingStepCard.tsx | 17 +- .../onboarding/StepCard.render.test.tsx | 63 ++++- src/components/onboarding/StepCard.tsx | 15 +- .../OnboardingContext.render.test.tsx | 4 +- src/hooks/useOnboardingChecklist.test.ts | 243 +++++++++++++++++- src/hooks/useOnboardingChecklist.ts | 131 +++++++++- .../useOnboardingChecklist.wiring.test.ts | 134 +++++++++- src/i18n/locales/en.json | 5 +- src/i18n/locales/fr.json | 5 +- src/pages/BudgetPage.render.test.tsx | 12 +- src/pages/BudgetPage.wiring.test.ts | 12 +- src/pages/DashboardPage.render.test.tsx | 9 +- src/pages/DashboardPage.tsx | 1 + src/pages/ImportPage.render.test.tsx | 18 +- src/pages/TransactionsPage.render.test.tsx | 14 +- src/services/onboardingService.test.ts | 150 ++++++++++- src/services/onboardingService.ts | 90 ++++++- src/shared/types/index.ts | 20 +- src/utils/onboardingChecklist.test.ts | 185 +++++++++++-- src/utils/onboardingChecklist.ts | 39 ++- src/utils/onboardingPath.test.ts | 66 ++++- src/utils/onboardingPath.ts | 9 +- 32 files changed, 1391 insertions(+), 138 deletions(-) diff --git a/src/components/dashboard/DashboardEmptyState.render.test.tsx b/src/components/dashboard/DashboardEmptyState.render.test.tsx index 4c6adcc..419b211 100644 --- a/src/components/dashboard/DashboardEmptyState.render.test.tsx +++ b/src/components/dashboard/DashboardEmptyState.render.test.tsx @@ -15,11 +15,18 @@ import { computeChecklist } from "../../utils/onboardingChecklist"; const checklist = computeChecklist( { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 }, - { importFolderSet: false, budgetAllowed: true }, + { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true }, ); function cardProps(hidden = false) { - return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() }; + return { + checklist, + budgetReady: true, + visitStepsInitialized: true, + hidden, + onHide: vi.fn(), + onResume: vi.fn(), + }; } const markup = (hidden = false) => @@ -36,7 +43,7 @@ describe("DashboardEmptyState (#352, #397)", () => { expect(html).toContain("t(dashboard.empty.description)"); expect(html).toMatch(/]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/); expect(html).toContain('aria-label="t(onboarding.checklist.title)"'); - expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])"); + expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])"); }); it("keeps the card and drops the list once the path is hidden", () => { @@ -52,6 +59,13 @@ describe("DashboardEmptyState (#352, #397)", () => { expect(list.type).toBe(OnboardingChecklist); expect(list.props.items).toBe(checklist.items); expect(list.props.budgetReady).toBe(true); + expect(list.props.visitStepsInitialized).toBe(true); + const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{ + children?: ReactNode; + }>; + expect( + (waiting.props.children as ReactElement>).props.visitStepsInitialized, + ).toBe(false); expect(list.props.onHide).toBe(props.onHide); expect(list.props.onResume).toBe(props.onResume); }); diff --git a/src/components/dashboard/DashboardEmptyState.tsx b/src/components/dashboard/DashboardEmptyState.tsx index bbef967..963791b 100644 --- a/src/components/dashboard/DashboardEmptyState.tsx +++ b/src/components/dashboard/DashboardEmptyState.tsx @@ -8,6 +8,8 @@ import type { ChecklistItemKey } from "../../shared/types"; interface DashboardEmptyStateProps { checklist: Checklist; budgetReady: boolean; + /** The visit steps are initialized (#425): their rows stay "pending" until then. */ + visitStepsInitialized: boolean; /** The user dismissed the checklist: the welcome card stays, the list goes. */ hidden: boolean; onHide: () => void; @@ -24,6 +26,7 @@ interface DashboardEmptyStateProps { export default function DashboardEmptyState({ checklist, budgetReady, + visitStepsInitialized, hidden, onHide, onResume, @@ -43,6 +46,7 @@ export default function DashboardEmptyState({ done={checklist.done} total={checklist.total} budgetReady={budgetReady} + visitStepsInitialized={visitStepsInitialized} onHide={onHide} onResume={onResume} /> diff --git a/src/components/dashboard/OnboardingChecklist.test.tsx b/src/components/dashboard/OnboardingChecklist.test.tsx index b3b0a40..0b53bf7 100644 --- a/src/components/dashboard/OnboardingChecklist.test.tsx +++ b/src/components/dashboard/OnboardingChecklist.test.tsx @@ -67,8 +67,14 @@ function render( counters = ZERO, budgetAllowed = true, skipped: ChecklistItemKey[] = [], + visitStepsInitialized = true, ) { - const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped }); + const checklist = computeChecklist(counters, { + importFolderSet: false, + budgetAllowed, + skipped, + visitStepsInitialized, + }); const onHide = vi.fn(); const onResume = vi.fn(); const props: OnboardingChecklistProps = { @@ -76,6 +82,7 @@ function render( done: checklist.done, total: checklist.total, budgetReady: true, + visitStepsInitialized, onHide, onResume, ...overrides, @@ -97,12 +104,12 @@ function stateTextsOf(row: AnyElement): string[] { } describe("OnboardingChecklist", () => { - it("renders the title, the counter 1 / 6 and the hide button", () => { + it("renders the title, the counter 1 / 8 and the hide button", () => { const { element, onHide } = render(); const text = texts(element); expect(text).toContain("t(onboarding.checklist.title)"); expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)"); - expect(text).toContain("t(onboarding.checklist.counter[done=1,total=6])"); + expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])"); const [hide] = find(element, (e) => e.type === "button"); expect(hide.props.type).toBe("button"); expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)"); @@ -110,11 +117,22 @@ describe("OnboardingChecklist", () => { expect(onHide).toHaveBeenCalledTimes(1); }); - it("renders six rows with their i18n labels", () => { + it("renders eight rows, in order, with their i18n labels (#425)", () => { const { element } = render(); - expect(find(element, (e) => e.type === "li")).toHaveLength(6); + const rows = find(element, (e) => e.type === "li"); + const order = [ + "profile", + "importFolder", + "source", + "import", + "categories", + "manualCategory", + "budget", + "reports", + ]; + expect(rows.map((r) => r.key)).toEqual(order); const text = texts(element); - for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) { + for (const key of order) { expect(text).toContain(`t(onboarding.checklist.items.${key})`); } }); @@ -154,7 +172,7 @@ describe("OnboardingChecklist", () => { expect(link.props.to).toBe("/settings/users"); // The license card carries no step card: nothing to focus there. expect(link.props.state).toBeUndefined(); - expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=5])"); + expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])"); }); it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => { @@ -209,7 +227,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => { ); // The hidden "(skipped)" text already says it: the badge is not read twice. expect(badge.props["aria-hidden"]).toBe("true"); - expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=6])"); + expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])"); }); it("'Resume' takes the step back, then opens its page with the step's navigation state", () => { @@ -250,3 +268,51 @@ describe("OnboardingChecklist — skipped rows (#397)", () => { expect(link.props.to).toBe("/settings/users"); }); }); + +describe("OnboardingChecklist — visit steps (#425)", () => { + it("a visit row to do links to its page with the step's navigation state", () => { + const { element } = render(); + const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link); + expect(categories.props.to).toBe("/categories"); + expect(categories.props.state).toEqual({ onboarding: { focus: true } }); + const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link); + expect(reports.props.to).toBe("/reports"); + }); + + it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => { + const { element } = render({}, ZERO, false, [], false); + for (const key of ["categories", "reports"]) { + const row = rowOf(element, key); + expect(find(row, (e) => e.type === Lock), key).toHaveLength(0); + expect(find(row, (e) => e.type === Circle), key).toHaveLength(1); + expect(find(row, (e) => e.type === Link), key).toHaveLength(0); + expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)"); + expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]); + } + // The Budget row keeps its own rule: entitlement known, Free → locked. + expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]); + expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false); + }); + + it("the Budget row's pending state follows budgetReady alone", () => { + const { element } = render({ budgetReady: false }, ZERO, true, [], true); + expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]); + expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]); + const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element; + expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]); + }); + + it("an acknowledged visit row shows as done", () => { + const checklist = computeChecklist(ZERO, { + importFolderSet: false, + budgetAllowed: true, + visitStepsInitialized: true, + acknowledged: ["reports"], + }); + const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total }); + const reports = rowOf(element, "reports"); + expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1); + expect(find(reports, (e) => e.type === Link)).toHaveLength(0); + expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])"); + }); +}); diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx index f4fac18..ea9a0b5 100644 --- a/src/components/dashboard/OnboardingChecklist.tsx +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -2,13 +2,13 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react"; import { stepNavState } from "../../utils/onboardingPath"; -import type { ChecklistItem, ChecklistItemKey } from "../../shared/types"; +import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types"; export interface OnboardingChecklistProps { items: ChecklistItem[]; /** Number of unlocked rows that are done or skipped. */ done: number; - /** Number of unlocked rows (6, or 5 when Budget is locked). */ + /** Number of unlocked rows (8, or 7 when Budget is locked). */ total: number; /** * False until the Budget entitlement is known. The budget row is then @@ -16,6 +16,12 @@ export interface OnboardingChecklistProps { * `total` depends on the lock. */ budgetReady: boolean; + /** + * False until the profile's visit steps are initialized (#425). Their two + * rows are then neutral ("pending", never "locked", which would show the + * Base edition badge) and the counter is hidden, `total` depending on them. + */ + visitStepsInitialized: boolean; onHide: () => void; /** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */ onResume: (key: ChecklistItemKey) => void; @@ -28,8 +34,13 @@ type RowState = "done" | "skipped" | "todo" | "locked" | "pending"; * done shows as done even if it was skipped too: "Resume" would bring back * nothing to do. */ -function rowState(item: ChecklistItem, budgetReady: boolean): RowState { +function rowState( + item: ChecklistItem, + budgetReady: boolean, + visitStepsInitialized: boolean, +): RowState { if (item.key === "budget" && !budgetReady) return "pending"; + if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending"; if (item.locked) return "locked"; if (item.done) return "done"; return item.skipped ? "skipped" : "todo"; @@ -75,6 +86,7 @@ export default function OnboardingChecklist({ done, total, budgetReady, + visitStepsInitialized, onHide, onResume, }: OnboardingChecklistProps) { @@ -88,7 +100,7 @@ export default function OnboardingChecklist({ >

{t("onboarding.checklist.title")}

- {budgetReady && ( + {budgetReady && visitStepsInitialized && ( {t("onboarding.checklist.counter", { done, total })} @@ -105,7 +117,7 @@ export default function OnboardingChecklist({