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_<key> 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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-13 21:32:20 -04:00
parent fade01d955
commit 64a07f359f
32 changed files with 1391 additions and 138 deletions

View file

@ -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(/<a [^>]*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<Record<string, unknown>>).props.visitStepsInitialized,
).toBe(false);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
});

View file

@ -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}
/>

View file

@ -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])");
});
});

View file

@ -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({
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{budgetReady && (
{budgetReady && visitStepsInitialized && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
@ -105,7 +117,7 @@ export default function OnboardingChecklist({
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const state = rowState(item, budgetReady, visitStepsInitialized);
const label = t(`onboarding.checklist.items.${item.key}`);
const stateText =
state === "todo" ? null : (

View file

@ -39,12 +39,19 @@ const NEW_PROFILE: OnboardingSnapshot = {
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: [],
visitStepsInitialized: true,
};
/** Folder, source and import done: the next step is the manual category, on /transactions. */
/**
* Folder, source and import done: the next step is the manual category, on
* /transactions. Stopped after the import, so the visit steps are
* acknowledged (rule of the templates, #425).
*/
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
acknowledged: ["categories", "reports"],
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
@ -75,6 +82,8 @@ function pathAt(
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
@ -84,6 +93,7 @@ function pathAt(
counters: state.counters,
hidden: state.hidden,
budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep,
mode,
cardPage,
@ -91,6 +101,7 @@ function pathAt(
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
@ -182,9 +193,9 @@ describe("AppShell — the getting-started banner (#397)", () => {
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)", () => {
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
expect(value.checklist?.total).toBe(5);
expect(value.checklist?.total).toBe(7);
expect(value.nextStep).toBeNull();
expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete");
@ -209,10 +220,19 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=4,total=6])",
"t(onboarding.banner.progress[done=6,total=8])",
);
});
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
expect(complete).not.toContain(BANNER);
expect(complete).not.toContain("onboarding.banner.next");
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');

View file

@ -43,13 +43,20 @@ function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[]
function onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
@ -57,6 +64,7 @@ function onboardingValue(): OnboardingValue {
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
@ -82,16 +90,23 @@ describe("OnboardingBannerSlot (#397)", () => {
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.visitStepsInitialized).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
ctx.onboarding = {
...onboardingValue(),
mode: "none",
budgetReady: false,
visitStepsInitialized: false,
};
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
expect(props.visitStepsInitialized).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {

View file

@ -24,6 +24,7 @@ export function OnboardingBannerSlot() {
checklist={onboarding.checklist}
nextStep={onboarding.nextStep}
budgetReady={onboarding.budgetReady}
visitStepsInitialized={onboarding.visitStepsInitialized}
listOpen={listOpen}
onToggleList={() => setListOpen((open) => !open)}
onHide={onboarding.hide}

View file

@ -44,7 +44,7 @@ const ZERO: OnboardingCounters = {
budgetEntries: 0,
transactions: 0,
};
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
/** Profile, folder and source done: 3/8, the next step is "import" on /import. */
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
@ -60,12 +60,19 @@ function bannerProps(
overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// A template stopped after the import has its visit steps acknowledged (#425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
});
return {
mode: checklist.complete ? "complete" : "banner",
checklist,
nextStep: nextStep(checklist),
budgetReady: true,
visitStepsInitialized: true,
listOpen: false,
onToggleList: vi.fn(),
onHide: vi.fn(),
@ -94,7 +101,7 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const html = markup(bannerProps());
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
expect(html).toContain("t(onboarding.banner.progress[done=3,total=8])");
expect(html).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)");
@ -117,6 +124,12 @@ describe("OnboardingBanner — banner mode (#397)", () => {
expect(html).toMatch(/href="\/import"/);
});
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
const html = markup(bannerProps({ visitStepsInitialized: false }));
expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/);
});
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps());
expect(html).toMatch(
@ -134,7 +147,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
for (const key of [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
]) {
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
}
});
@ -144,8 +166,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3);
expect(list.props.total).toBe(6);
expect(list.props.total).toBe(8);
expect(list.props.budgetReady).toBe(true);
expect(list.props.visitStepsInitialized).toBe(true);
// Each flag reaches the list on its own: the Budget row's pending state follows budgetReady alone.
const [waiting] = find(
tree(bannerProps({ listOpen: true, visitStepsInitialized: false })),
(e) => e.type === OnboardingChecklist,
);
expect(waiting.props.budgetReady).toBe(true);
expect(waiting.props.visitStepsInitialized).toBe(false);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);

View file

@ -24,6 +24,8 @@ export interface OnboardingBannerProps {
nextStep: ChecklistItem | null;
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
budgetReady: boolean;
/** False until the visit steps are initialized (#425): the counter waits too, its total depends on them. */
visitStepsInitialized: boolean;
/** The detailed list is unfolded under the banner. */
listOpen: boolean;
onToggleList: () => void;
@ -51,7 +53,7 @@ function BannerIcon() {
*
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
* which unfolds the detailed list in place, and "Hide". The counter waits
* for `budgetReady`, like the list's own counter.
* for `budgetReady && visitStepsInitialized`, like the list's own counter.
* - `complete` (D4): the end message, "Open the guide" and "Close", which
* hides the path until it is shown again from Settings → Users.
* - Anything else (`none`, `card`) renders nothing.
@ -61,12 +63,14 @@ export default function OnboardingBanner({
checklist,
nextStep,
budgetReady,
visitStepsInitialized,
listOpen,
onToggleList,
onHide,
onResume,
}: OnboardingBannerProps) {
const { t } = useTranslation();
const totalKnown = budgetReady && visitStepsInitialized;
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
if (mode === "complete") {
@ -92,12 +96,12 @@ export default function OnboardingBanner({
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
{budgetReady && (
{totalKnown && (
<span className="font-semibold text-[var(--foreground)] tabular-nums">
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
</span>
)}
{budgetReady && nextStep && (
{totalKnown && nextStep && (
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
)}
{nextStep && (
@ -133,6 +137,7 @@ export default function OnboardingBanner({
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
visitStepsInitialized={visitStepsInitialized}
onHide={onHide}
onResume={onResume}
/>

View file

@ -21,6 +21,21 @@ vi.mock("react-router-dom", async (importOriginal) => ({
useNavigate: () => ctx.navigate,
}));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
// /categories guides no step before its card ships (#432): a stand-in page
// that guides the categories step, so the wiring of "Got it" is exercised now.
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
return {
...orig,
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
const [checklist, pathname] = args;
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
const unlocked = checklist.items.filter((item) => !item.locked);
const step = unlocked.find((item) => item.key === "categories")!;
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
},
};
});
import { hookHarness } from "../../test/hookHarness";
import OnboardingStepCard from "./OnboardingStepCard";
@ -46,13 +61,20 @@ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transact
/** The path latched on /import, as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
// A template stopped after the import has its visit steps acknowledged (#425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
@ -60,6 +82,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -125,7 +148,7 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
const props = render()!.props;
expect(props.step.key).toBe("source");
expect(props.position).toEqual({ n: 3, total: 6 });
expect(props.position).toEqual({ n: 3, total: 8 });
expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false);
expect(props.titleRef).toBe(titleRef());
@ -147,6 +170,39 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(render()!.props.position).toBeNull();
});
it("no 'Step n of N' and no end until the visit steps are initialized (#425)", () => {
ctx.onboarding = path(FOLDER_SET, { visitStepsInitialized: false });
expect(render()!.props.position).toBeNull();
ctx.onboarding = path(IMPORTED, { visitStepsInitialized: false, nextStep: null });
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
ctx.onboarding = path(IMPORTED, { nextStep: null });
expect(render(DONE)!.props.next).toEqual({ kind: "end" });
});
it("no 'Got it' on a data step", () => {
expect(render()!.props.onAcknowledge).toBeUndefined();
});
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
ctx.onboarding = path(IMPORTED, {
cardPage: "/stand-in-categories",
checklist: computeChecklist(IMPORTED, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
}),
});
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
expect(props.step.key).toBe("categories");
expect(props.position).toEqual({ n: 5, total: 8 });
expect(props.onAcknowledge).toBeTypeOf("function");
props.onAcknowledge!();
expect(value.acknowledge).toHaveBeenCalledWith("categories");
expect(value.skip).not.toHaveBeenCalled();
});
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;

View file

@ -9,6 +9,7 @@ import {
showsStepCard,
type StepCardModel,
} from "../../utils/onboardingPath";
import { VISIT_STEP_KEYS } from "../../shared/types";
export interface OnboardingStepCardProps {
/** The page's card model; null for no card yet (the page's own data is not read). */
@ -28,8 +29,11 @@ export interface OnboardingStepCardProps {
* then consumes the navigation state, once per arrival: StrictMode runs the
* effect twice on mount, a guard on the location key keeps it to one focus
* and one replace. An ordinary visit carries no state and does nothing.
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
* the next step's link to the path's next step.
* - It wires "Skip this step" to the step it shows, "Got it" to
* `acknowledge` when that step is a visit step (#425), "Close" to `hide`,
* and the next step's link to the path's next step.
* - Its rank "Step n of N" and its end wait for the Budget entitlement and
* the visit steps' initialization (#425): N depends on both.
*/
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
const onboarding = useOnboarding();
@ -55,15 +59,18 @@ export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
if (!shown) return null;
const { skip, hide } = onboarding;
const { skip, acknowledge, hide } = onboarding;
const totalKnown = onboarding.budgetReady && onboarding.visitStepsInitialized;
const isVisitStep = VISIT_STEP_KEYS.includes(current.step.key);
return (
<StepCard
step={current.step}
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
position={totalKnown ? { n: current.n, total: current.total } : null}
text={model.text}
done={model.done}
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
next={cardNext(onboarding.nextStep, pathname, totalKnown)}
onSkip={() => void skip(current.step.key)}
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
onHide={() => void hide()}
titleRef={titleRef}
/>

View file

@ -40,15 +40,19 @@ const ZERO: OnboardingCounters = {
transactions: 0,
};
const rows = (counters: OnboardingCounters = ZERO) =>
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
}).items;
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
rows(counters).find((item) => item.key === key)!;
/** The source step on /import, step 3 of 6, not done yet. */
/** The source step on /import, step 3 of 8, not done yet. */
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
return {
step: row("source"),
position: { n: 3, total: 6 },
position: { n: 3, total: 8 },
text: { key: "onboarding.card.import.sourceList" },
done: false,
next: { kind: "wait" },
@ -62,7 +66,7 @@ function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
position: { n: 4, total: 6 },
position: { n: 4, total: 8 },
text: null,
done: true,
next: { kind: "step", step: row("manualCategory") },
@ -92,7 +96,7 @@ describe("StepCard — the card (#398)", () => {
expect(heading).not.toBeNull();
expect(heading![0]).toContain('tabindex="-1"');
const title = heading![1];
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=8])");
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
title.indexOf("t(onboarding.checklist.items.source)"),
);
@ -204,3 +208,52 @@ describe("StepCard — end of the path (#398)", () => {
expect(html).toContain("t(onboarding.card.skip)");
});
});
describe("StepCard — \"Got it\" on a visit step (#425)", () => {
/** The categories step, step 5 of 8, not acknowledged yet. */
const visitProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({
step: row("categories"),
position: { n: 5, total: 8 },
text: null,
onAcknowledge: vi.fn(),
...overrides,
});
it("offers 'Got it' before 'Skip this step', wired to onAcknowledge", () => {
const props = visitProps();
const html = markup(props);
expect(html).toContain("t(onboarding.card.acknowledge)");
expect(html.indexOf("t(onboarding.card.acknowledge)")).toBeLessThan(
html.indexOf("t(onboarding.card.skip)"),
);
const buttons = find(tree(props), (e) => e.type === "button");
expect(buttons.map((b) => texts(b.props.children))).toEqual([
["t(onboarding.card.acknowledge)"],
["t(onboarding.card.skip)"],
]);
(buttons[0].props.onClick as () => void)();
expect(props.onAcknowledge).toHaveBeenCalledTimes(1);
expect(props.onSkip).not.toHaveBeenCalled();
});
it("is never there without onAcknowledge", () => {
expect(markup(cardProps())).not.toContain("onboarding.card.acknowledge");
expect(markup(visitProps({ onAcknowledge: undefined }))).not.toContain(
"onboarding.card.acknowledge",
);
});
it("is never there on a done step, nor once the page's steps are done", () => {
const acknowledged = computeChecklist(ZERO, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories"],
}).items.find((item) => item.key === "categories")!;
expect(markup(visitProps({ step: acknowledged }))).not.toContain("onboarding.card.acknowledge");
const done = markup(visitProps({ done: true, next: { kind: "step", step: row("manualCategory") } }));
expect(done).not.toContain("onboarding.card.acknowledge");
expect(done).toContain("t(onboarding.card.done)");
});
});

View file

@ -13,7 +13,7 @@ import type { ChecklistItem } from "../../shared/types";
export interface StepCardProps {
/** The step the card shows (`cardStep`): its label is the title. */
step: ChecklistItem;
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
/** "Step n of N", null until the Budget entitlement is known and the visit steps initialized: N depends on both. */
position: { n: number; total: number } | null;
/** The page's sentence, null when "Done." says it all. */
text: StepCardText | null;
@ -23,6 +23,8 @@ export interface StepCardProps {
next: StepCardNext;
/** "Skip this step": the provider's `skip`, for `step`. */
onSkip: () => void;
/** "Got it" on a visit step (#425): the provider's `acknowledge`, for `step`. No button when omitted. */
onAcknowledge?: () => void;
/** "Close" at the end of the path: the provider's `hide`. */
onHide: () => void;
/** The title, which takes the focus after an arrival by "Next step". */
@ -33,6 +35,8 @@ const REGION_CLASS =
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
const TEXT_BUTTON_CLASS =
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
const ACKNOWLEDGE_BUTTON_CLASS =
"rounded-lg bg-[var(--primary)] px-3 py-1 font-medium text-white hover:opacity-90 transition-opacity";
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
/**
@ -48,7 +52,8 @@ const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--prima
* "Done." it receives once the page's steps are done is announced.
* - "Skip this step" shows until the page's steps are done, and only while
* the step itself is not: skipping a done step would only leave a stale
* "skipped" mark behind.
* "skipped" mark behind. A visit step (#425) gets "Got it" before it, on
* the same condition, when `onAcknowledge` is given.
* - Once done: "Next step: <step> →", linked to its page with the step's
* navigation state; the end message, "Open the guide" and "Close" when no
* step is left; nothing yet while `next` waits.
@ -60,6 +65,7 @@ export default function StepCard({
done,
next,
onSkip,
onAcknowledge,
onHide,
titleRef,
}: StepCardProps) {
@ -105,6 +111,11 @@ export default function StepCard({
</>
)}
</p>
{!done && !step.done && onAcknowledge && (
<button type="button" onClick={onAcknowledge} className={ACKNOWLEDGE_BUTTON_CLASS}>
{t("onboarding.card.acknowledge")}
</button>
)}
{!done && !step.done && (
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
{t("onboarding.card.skip")}

View file

@ -27,6 +27,8 @@ function Probe() {
String(onboarding.checklist),
typeof onboarding.skip,
typeof onboarding.refresh,
typeof onboarding.acknowledge,
String(onboarding.visitStepsInitialized),
].join("|")}
</output>
);
@ -51,7 +53,7 @@ describe("OnboardingProvider (#396)", () => {
</OnboardingProvider>
</MemoryRouter>,
);
expect(html).toContain("loading|none|null|null|null|function|function");
expect(html).toContain("loading|none|null|null|null|function|function|function|false");
});
it("AppShell mounts the provider around the routed page", () => {

View file

@ -21,7 +21,9 @@ import {
onboardingChecklistReducer,
initialOnboardingChecklistState,
createOnboardingLoader,
createVisitStepsInitializer,
deriveOnboardingView,
needsVisitStepsInitialization,
followDataChanges,
DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction,
@ -29,7 +31,7 @@ import {
type OnboardingLoaderDeps,
} from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents";
import { computeChecklist } from "../utils/onboardingChecklist";
import { computeChecklist, shouldAcknowledgeVisitSteps } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService";
import type { OnboardingCounters } from "../shared/types";
@ -38,6 +40,10 @@ const snapshot: OnboardingSnapshot = {
importFolderSet: true,
hidden: false,
skipped: [],
// Stopped after the import: the visit steps are acknowledged and initialized
// (rule of the templates, #425).
acknowledged: ["categories", "reports"],
visitStepsInitialized: true,
};
const reduce = (
@ -60,9 +66,12 @@ describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: [],
visitStepsInitialized: false,
loadSeq: 0,
hiddenSetAt: 0,
skippedSetAt: 0,
acknowledgedSetAt: 0,
cardPage: null,
});
});
@ -318,6 +327,76 @@ describe("onboardingChecklistReducer — skipped steps and the card latch (#396)
).toBe("/import");
});
it("ACKNOWLEDGE keeps checklist order, never duplicates a step, and keeps the card latch (#425)", () => {
const latched = reduce(
[{ type: "SET_CARD_PAGE", cardPage: "/categories" }],
ready({ ...snapshot, acknowledged: [] }),
);
const s = reduce(
[
{ type: "ACKNOWLEDGE", key: "reports" },
{ type: "ACKNOWLEDGE", key: "categories" },
{ type: "ACKNOWLEDGE", key: "reports" },
],
latched,
);
expect(s.acknowledged).toEqual(["categories", "reports"]);
expect(s.cardPage).toBe("/categories");
expect(s.skipped).toEqual([]);
});
it("ACKNOWLEDGE during a load wins over that load's snapshot; a later load brings the stored value back (#425)", () => {
const base = ready({ ...snapshot, acknowledged: [] });
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "ACKNOWLEDGE", key: "categories" },
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, acknowledged: [] } },
],
base,
);
expect(s.acknowledged).toEqual(["categories"]);
const later = reduce(
[
{ type: "LOAD_START", seq: 3 },
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["categories"] } },
],
s,
);
expect(later.acknowledged).toEqual(["categories"]);
});
it("ACKNOWLEDGE does not shield the skipped steps, nor SKIP the acknowledged ones (#425)", () => {
const base = ready({ ...snapshot, acknowledged: [] });
const s = reduce(
[
{ type: "LOAD_START", seq: 2 },
{ type: "ACKNOWLEDGE", key: "categories" },
{
type: "LOAD_DONE",
seq: 2,
snapshot: { ...snapshot, acknowledged: [], skipped: ["budget"] },
},
],
base,
);
expect(s.skipped).toEqual(["budget"]);
const t = reduce(
[
{ type: "LOAD_START", seq: 3 },
{ type: "SKIP", key: "budget" },
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["reports"] } },
],
s,
);
expect(t.acknowledged).toEqual(["reports"]);
});
it("LOAD_DONE takes visitStepsInitialized from the snapshot (#425)", () => {
expect(ready({ ...snapshot, visitStepsInitialized: false }).visitStepsInitialized).toBe(false);
expect(ready(snapshot).visitStepsInitialized).toBe(true);
});
it("SET_CARD_PAGE with the same page returns the same state object", () => {
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
@ -465,6 +544,82 @@ describe("createOnboardingLoader (#396)", () => {
});
});
// ---------------------------------------------------------------------------
// Visit steps' one-time initialization (#425)
// ---------------------------------------------------------------------------
describe("createVisitStepsInitializer (#425)", () => {
it("runs initializeVisitSteps once, then re-reads, whatever the number of calls", async () => {
const init = deferred<void>();
const deps = {
initializeVisitSteps: vi.fn(() => init.promise),
refresh: vi.fn(async () => {}),
logError: vi.fn(),
};
const run = createVisitStepsInitializer(deps);
// StrictMode's double mount, then renders before the re-read lands.
const first = run(true);
const second = run(false);
expect(deps.refresh).not.toHaveBeenCalled();
init.resolve();
await Promise.all([first, second]);
await run(true);
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(deps.initializeVisitSteps).toHaveBeenCalledWith(true);
expect(deps.refresh).toHaveBeenCalledTimes(1);
});
it("a failure is logged, without a re-read, and not retried by the same instance", async () => {
const deps = {
initializeVisitSteps: vi.fn(async () => {
throw new Error("database is locked");
}),
refresh: vi.fn(async () => {}),
logError: vi.fn(),
};
const run = createVisitStepsInitializer(deps);
await run(false);
await run(false);
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(deps.refresh).not.toHaveBeenCalled();
expect(deps.logError).toHaveBeenCalledWith(
"Onboarding: failed to initialize the visit steps",
"database is locked",
);
});
});
describe("needsVisitStepsInitialization (#425)", () => {
const notYet = { ...snapshot, acknowledged: [], visitStepsInitialized: false };
const listOf = (state: OnboardingChecklistState) =>
computeChecklist(state.counters!, {
importFolderSet: state.importFolderSet,
budgetAllowed: true,
visitStepsInitialized: state.visitStepsInitialized,
});
it("true after a successful load, with the entitlement known and the flag unset", () => {
const s = ready(notYet);
expect(needsVisitStepsInitialization(s, listOf(s), true)).toBe(true);
});
it("false before budgetReady: the checklist would count Budget as locked", () => {
const s = ready(notYet);
expect(needsVisitStepsInitialization(s, listOf(s), false)).toBe(false);
});
it("false before any load, after a failed first load, and once the flag is set", () => {
expect(needsVisitStepsInitialization(initialOnboardingChecklistState, null, true)).toBe(false);
const failed = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
expect(needsVisitStepsInitialization(failed, null, true)).toBe(false);
const done = ready(snapshot);
expect(needsVisitStepsInitialization(done, listOf(done), true)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Provider view: the path end to end, without rendering
// ---------------------------------------------------------------------------
@ -492,6 +647,8 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
})
: null;
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
@ -517,7 +674,16 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
);
}
const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
// The walks below cover the six data steps: the visit steps are acknowledged
// and initialized (rule of the templates, #425); their own walk follows.
const fresh: OnboardingSnapshot = {
counters: empty,
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: ["categories", "reports"],
visitStepsInitialized: true,
};
it("shows nothing before the first load", () => {
const { view } = render(initialOnboardingChecklistState, "/import");
@ -652,7 +818,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
}
});
it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
it("Free edition: seven steps, never the budget card, and the end at 7/7", () => {
const snap = {
...fresh,
importFolderSet: true,
@ -660,7 +826,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
};
const s = load(initialOnboardingChecklistState, snap);
const r = render(s, "/budget", { budgetAllowed: false });
expect(r.checklist?.total).toBe(5);
expect(r.checklist?.total).toBe(7);
expect(r.view.nextStep).toBeNull();
expect(r.state.cardPage).toBeNull();
expect(r.view.mode).toBe("complete");
@ -677,7 +843,74 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
);
r = render(s, "/import");
expect(r.view.mode).toBe("card");
expect(r.checklist?.total).toBe(6);
expect(r.checklist?.total).toBe(8);
});
// The visit steps (#425): until they are initialized, a profile whose path
// was complete must not flash a "Next step" towards them.
const sixDone = { ...empty, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
const beforeInit: OnboardingSnapshot = {
...fresh,
importFolderSet: true,
counters: sixDone,
acknowledged: [],
visitStepsInitialized: false,
};
it("first render of a profile whose path was complete: no banner before the initialization, then the end", () => {
let s = load(initialOnboardingChecklistState, beforeInit);
let r = render(s, "/reports");
expect(r.view.nextStep).toBeNull();
expect(r.view.mode).toBe("none");
expect(r.view.mode).not.toBe("banner");
expect(needsVisitStepsInitialization(s, r.checklist, true)).toBe(true);
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(true);
// The initializer wrote both acknowledgements and the flag, then re-read.
s = load(r.state, { ...beforeInit, acknowledged: ["categories", "reports"], visitStepsInitialized: true });
r = render(s, "/reports");
expect(r.checklist?.total).toBe(8);
expect(r.view.mode).toBe("complete");
});
it("an incomplete path keeps its visit steps to do: after the import, the next step is to browse the categories", () => {
const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
let s = load(initialOnboardingChecklistState, {
...beforeInit,
counters: imported,
});
let r = render(s, "/import");
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(false);
s = load(r.state, { ...beforeInit, counters: imported, visitStepsInitialized: true });
r = render(s, "/import");
expect(r.view.nextStep?.key).toBe("categories");
expect(r.checklist?.total).toBe(8);
// The six other steps done: still two to do.
s = load(r.state, { ...beforeInit, visitStepsInitialized: true });
r = render(s, "/");
expect(r.view.nextStep?.key).toBe("categories");
expect(r.view.mode).toBe("banner");
});
it('"Got it" on the last step ends the path; the latched card stays to say so', () => {
let s = load(initialOnboardingChecklistState, {
...beforeInit,
acknowledged: ["categories"],
visitStepsInitialized: true,
});
s = reduce([{ type: "SET_CARD_PAGE", cardPage: "/reports" }], s);
let r = render(s, "/reports", {});
expect(r.view.nextStep?.key).toBe("reports");
r = render(reduce([{ type: "ACKNOWLEDGE", key: "reports" }], r.state), "/reports");
expect(r.view.nextStep).toBeNull();
expect(r.checklist?.complete).toBe(true);
expect(r.state.cardPage).toBe("/reports");
expect(r.view.mode).toBe("card");
});
it("the end waits for the visit steps' initialization even with the entitlement known", () => {
const s = load(initialOnboardingChecklistState, beforeInit);
expect(render(s, "/").view.mode).toBe("none");
expect(render(load(s, { ...beforeInit, visitStepsInitialized: true }), "/").view.mode).toBe("banner");
});
});

View file

@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { useLocation } from "react-router-dom";
import {
acknowledgeStep,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
initializeVisitSteps,
resumeStep,
showChecklist,
skipStep,
@ -11,7 +13,11 @@ import {
} from "../services/onboardingService";
import { subscribeDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService";
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
import {
computeChecklist,
shouldAcknowledgeVisitSteps,
type Checklist,
} from "../utils/onboardingChecklist";
import {
bannerMode,
latchCardPage,
@ -44,12 +50,18 @@ export interface OnboardingChecklistState {
hidden: boolean;
/** Skipped steps, in checklist order. */
skipped: ChecklistItemKey[];
/** Visit steps done by "Got it" (#425), in checklist order. */
acknowledged: ChecklistItemKey[];
/** The profile's visit steps are initialized (#425); read from the snapshot only. */
visitStepsInitialized: boolean;
/** Number of the last load started: the outcome of any other load is stale. */
loadSeq: number;
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
hiddenSetAt: number;
/** Same, for SKIP / RESUME and the skipped steps. */
skippedSetAt: number;
/** Same, for ACKNOWLEDGE and the acknowledged steps (#425). */
acknowledgedSetAt: number;
/** The page that carries a step card (`latchCardPage`), null otherwise. */
cardPage: string | null;
}
@ -62,6 +74,7 @@ export type OnboardingChecklistAction =
| { type: "SHOW" }
| { type: "SKIP"; key: ChecklistItemKey }
| { type: "RESUME"; key: ChecklistItemKey }
| { type: "ACKNOWLEDGE"; key: ChecklistItemKey }
| { type: "SET_CARD_PAGE"; cardPage: string | null };
export const initialOnboardingChecklistState: OnboardingChecklistState = {
@ -70,9 +83,12 @@ export const initialOnboardingChecklistState: OnboardingChecklistState = {
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: [],
visitStepsInitialized: false,
loadSeq: 0,
hiddenSetAt: 0,
skippedSetAt: 0,
acknowledgedSetAt: 0,
cardPage: null,
};
@ -106,6 +122,9 @@ export function onboardingChecklistReducer(
// snapshot, read before the action's write (#384).
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
acknowledged:
action.seq <= state.acknowledgedSetAt ? state.acknowledged : snapshot.acknowledged,
visitStepsInitialized: snapshot.visitStepsInitialized,
};
}
case "LOAD_ERROR":
@ -132,6 +151,14 @@ export function onboardingChecklistReducer(
skipped: state.skipped.filter((key) => key !== action.key),
skippedSetAt: state.loadSeq,
};
case "ACKNOWLEDGE":
// Wins over an older snapshot like SKIP, but keeps the card latch: the
// card stays to say "Done." and offer the next step (#425).
return {
...state,
acknowledged: withStep(state.acknowledged, action.key),
acknowledgedSetAt: state.loadSeq,
};
case "SET_CARD_PAGE":
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
}
@ -199,6 +226,53 @@ export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promis
};
}
export interface VisitStepsInitializerDeps {
initializeVisitSteps: (acknowledge: boolean) => Promise<void>;
/** The provider's loader: the initializer re-reads once it wrote. */
refresh: () => Promise<void>;
logError: (...args: unknown[]) => void;
}
/**
* The one-time initialization of the visit steps (#425), kept outside React
* like the loader. The provider calls the returned function after each render
* where a load succeeded, the Budget entitlement is known and the flag is
* unset; it runs `initializeVisitSteps` once per instance, whatever the number
* of calls (the promise is shared, so the double mount of StrictMode and the
* renders before the re-read lands do not run it twice), then re-reads. A
* failure is logged and the flag stays unset: the next opening retries.
*/
export function createVisitStepsInitializer(
deps: VisitStepsInitializerDeps,
): (acknowledge: boolean) => Promise<void> {
let initialization: Promise<void> | null = null;
return (acknowledge) => {
if (initialization === null) {
initialization = deps.initializeVisitSteps(acknowledge).then(
() => deps.refresh(),
(e: unknown) => {
deps.logError("Onboarding: failed to initialize the visit steps", errorMessage(e));
},
);
}
return initialization;
};
}
/**
* Whether this render must start the visit steps' initialization (#425): a
* load succeeded, the Budget entitlement is known (the checklist counts Budget
* as locked until then, and a Base profile would be judged complete one step
* early) and the snapshot says the flag is unset.
*/
export function needsVisitStepsInitialization(
state: OnboardingChecklistState,
checklist: Checklist | null,
budgetReady: boolean,
): checklist is Checklist {
return state.status === "ready" && checklist !== null && budgetReady && !state.visitStepsInitialized;
}
/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
export const DATA_CHANGE_DELAY_MS = 150;
@ -255,6 +329,9 @@ export function deriveOnboardingView({
budgetReady,
cardPages,
}: OnboardingViewInput): OnboardingView {
// The end of the path waits for the visit steps too (#425): before their
// initialization, a profile whose path was complete is two steps short.
const totalKnown = budgetReady && state.visitStepsInitialized;
const nextStep = checklist ? findNextStep(checklist) : null;
const cardPage = latchCardPage(state.cardPage, {
pathname,
@ -268,7 +345,7 @@ export function deriveOnboardingView({
hidden: state.hidden,
pathname,
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
budgetReady,
budgetReady: totalKnown,
cardPage,
});
return { nextStep, cardPage, mode };
@ -282,6 +359,11 @@ export interface OnboardingValue {
counters: OnboardingCounters | null;
hidden: boolean;
budgetReady: boolean;
/**
* The profile's visit steps are initialized (#425). The counters, the rank
* of a card and the end of the path wait for `budgetReady && visitStepsInitialized`.
*/
visitStepsInitialized: boolean;
/** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
nextStep: ChecklistItem | null;
/** What the current page shows of the path (`bannerMode`). */
@ -292,18 +374,22 @@ export interface OnboardingValue {
show: () => Promise<void>;
skip: (key: ChecklistItemKey) => Promise<void>;
resume: (key: ChecklistItemKey) => Promise<void>;
/** "Got it" on a visit step's card (#425). */
acknowledge: (key: ChecklistItemKey) => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Getting-started path: counters read from the profile database, hidden and
* skipped preferences, the Budget entitlement that decides whether the sixth
* skipped and acknowledged preferences, the visit-steps flag (#425), the
* Budget entitlement that decides whether the seventh
* row is locked, and what each page shows of it. Called once, by
* `OnboardingProvider`; pages read it through `useOnboarding()`.
*
* Re-read at mount, at every route change and 150 ms after a burst of data
* events (ADR 0022). The provider is the only writer of the onboarding
* preferences: its actions update the state themselves, without a re-read.
* preferences: its actions update the state themselves, without a re-read;
* only the visit steps' one-time initialization (#425) re-reads after it wrote.
*/
export function useOnboardingChecklist(): OnboardingValue {
const [state, dispatch] = useReducer(
@ -319,6 +405,11 @@ export function useOnboardingChecklist(): OnboardingValue {
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
);
// Same lifetime: one visit-steps initialization per profile opening (#425).
const [initializeVisitStepsOnce] = useState(() =>
createVisitStepsInitializer({ initializeVisitSteps, refresh, logError }),
);
// Read at mount, then again at every route change.
useEffect(() => {
void refresh();
@ -363,6 +454,15 @@ export function useOnboardingChecklist(): OnboardingValue {
}
}, []);
const acknowledge = useCallback(async (key: ChecklistItemKey) => {
try {
await acknowledgeStep(key);
dispatch({ type: "ACKNOWLEDGE", key });
} catch (e) {
logError("Onboarding: failed to acknowledge a step", errorMessage(e));
}
}, []);
const checklist: Checklist | null = useMemo(
() =>
state.counters
@ -370,11 +470,28 @@ export function useOnboardingChecklist(): OnboardingValue {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
})
: null,
[state.counters, state.importFolderSet, state.skipped, budgetAllowed],
[
state.counters,
state.importFolderSet,
state.skipped,
state.acknowledged,
state.visitStepsInitialized,
budgetAllowed,
],
);
// The visit steps' one-time initialization (#425), after a successful load
// with the Budget entitlement known; the initializer re-reads itself.
const needsInitialization = needsVisitStepsInitialization(state, checklist, budgetReady);
const acknowledgeVisitSteps = checklist !== null && shouldAcknowledgeVisitSteps(checklist);
useEffect(() => {
if (needsInitialization) void initializeVisitStepsOnce(acknowledgeVisitSteps);
}, [needsInitialization, acknowledgeVisitSteps, initializeVisitStepsOnce]);
const { nextStep, cardPage, mode } = deriveOnboardingView({
state,
checklist,
@ -396,6 +513,7 @@ export function useOnboardingChecklist(): OnboardingValue {
counters: state.counters,
hidden: state.hidden,
budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep,
mode,
cardPage,
@ -403,6 +521,7 @@ export function useOnboardingChecklist(): OnboardingValue {
show,
skip,
resume,
acknowledge,
refresh,
}),
[
@ -411,6 +530,7 @@ export function useOnboardingChecklist(): OnboardingValue {
state.counters,
state.hidden,
budgetReady,
state.visitStepsInitialized,
nextStep,
mode,
cardPage,
@ -418,6 +538,7 @@ export function useOnboardingChecklist(): OnboardingValue {
show,
skip,
resume,
acknowledge,
refresh,
],
);

View file

@ -11,9 +11,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ pathname: "/" }));
const ctx = vi.hoisted(() => ({ pathname: "/", budgetReady: true }));
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
vi.mock("./useEntitlement", () => ({
useEntitlement: () => ({ allowed: true, ready: ctx.budgetReady }),
}));
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../services/onboardingService", () => ({
getOnboardingSnapshot: vi.fn(),
@ -22,6 +24,8 @@ vi.mock("../services/onboardingService", () => ({
showChecklist: vi.fn(),
skipStep: vi.fn(),
resumeStep: vi.fn(),
acknowledgeStep: vi.fn(),
initializeVisitSteps: vi.fn(),
}));
import { hookHarness } from "../test/hookHarness";
@ -34,9 +38,11 @@ import {
import { notifyDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService";
import {
acknowledgeStep,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
initializeVisitSteps,
resumeStep,
showChecklist,
skipStep,
@ -50,6 +56,10 @@ const snapshot: OnboardingSnapshot = {
importFolderSet: true,
hidden: false,
skipped: [],
// Stopped after the import: the visit steps are acknowledged and initialized
// (rule of the templates, #425).
acknowledged: ["categories", "reports"],
visitStepsInitialized: true,
};
const ready: OnboardingChecklistState = [
@ -76,9 +86,17 @@ const actionsOfType = (type: string) =>
beforeEach(() => {
harness.clear();
ctx.pathname = "/";
ctx.budgetReady = true;
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
for (const write of [
hideChecklist,
showChecklist,
skipStep,
resumeStep,
acknowledgeStep,
initializeVisitSteps,
]) {
vi.mocked(write).mockReset().mockResolvedValue(undefined);
}
vi.mocked(logError).mockReset();
@ -192,7 +210,7 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(value.nextStep?.key).toBe("manualCategory");
expect(value.mode).toBe("banner");
expect(value.cardPage).toBeNull();
expect(value.checklist?.done).toBe(4);
expect(value.checklist?.done).toBe(6);
});
it("each action writes its preference first, then updates the state", async () => {
@ -217,6 +235,32 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch);
});
it('"Got it" writes the acknowledgement first, then updates the state, keeping the card latch (#425)', async () => {
mountWith({ ...ready, acknowledged: [], cardPage: "/import" });
ctx.pathname = "/import";
const value = render();
await value.acknowledge("categories");
expect(acknowledgeStep).toHaveBeenCalledWith("categories");
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
{ type: "ACKNOWLEDGE", key: "categories" },
]);
expect(vi.mocked(acknowledgeStep).mock.invocationCallOrder[0]).toBeLessThan(
harness.dispatch.mock.invocationCallOrder[0],
);
const after = render();
expect(after.cardPage).toBe("/import");
expect(after.checklist?.items.find((i) => i.key === "categories")?.done).toBe(true);
});
it('a failed "Got it" leaves the state alone and logs (#425)', async () => {
mountWith({ ...ready, acknowledged: [] });
const value = render();
vi.mocked(acknowledgeStep).mockRejectedValueOnce(new Error("database is locked"));
await value.acknowledge("reports");
expect(harness.dispatch).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledWith("Onboarding: failed to acknowledge a step", "database is locked");
});
it("an action whose write fails leaves the state alone and logs", async () => {
mountWith(ready);
const value = render();
@ -283,3 +327,85 @@ describe("useOnboardingChecklist — what survives a render (#409)", () => {
expect(reports.mode).toBe("banner");
});
});
describe("useOnboardingChecklist — the visit steps' initialization (#425)", () => {
const sixDone = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
const notInitialized: OnboardingChecklistState = {
...ready,
counters: sixDone,
acknowledged: [],
visitStepsInitialized: false,
};
it("runs once, after a successful load with the entitlement known, then re-reads", async () => {
mountWith(notInitialized);
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
// The read of the mount plus the re-read of the initializer.
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
// Later renders, the re-read not landed yet (flag still unset in state): no second run.
harness.slots[0] = notInitialized;
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
});
it("an incomplete path initializes without acknowledging", async () => {
mountWith({ ...notInitialized, counters: { ...sixDone, budgetEntries: 0 } });
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledWith(false);
});
it("does not run before budgetReady, then runs once it is known", async () => {
ctx.budgetReady = false;
mountWith(notInitialized);
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
ctx.budgetReady = true;
harness.slots[0] = notInitialized;
render();
harness.commit();
await flush();
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
});
it("does not run before the first successful load, nor once the flag is set", async () => {
vi.mocked(getOnboardingSnapshot).mockReturnValue(new Promise(() => {}));
render();
harness.commit();
await flush();
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
harness.unmount();
harness.clear();
vi.mocked(getOnboardingSnapshot).mockResolvedValue(snapshot);
mountWith(ready);
render();
harness.commit();
await flush();
expect(initializeVisitSteps).not.toHaveBeenCalled();
});
it("shares visitStepsInitialized; before it, a complete path shows no 'Next step' banner", () => {
mountWith(notInitialized);
ctx.pathname = "/reports";
const value = render();
expect(value.visitStepsInitialized).toBe(false);
expect(value.nextStep).toBeNull();
expect(value.mode).toBe("none");
});
});

View file

@ -77,6 +77,7 @@
"card": {
"stepOf": "Step {{n}} of {{total}}",
"skip": "Skip this step",
"acknowledge": "Got it",
"done": "Done.",
"next": "Next step: {{step}} →",
"import": {
@ -117,8 +118,10 @@
"importFolder": "Choose an import folder",
"source": "Set up an import source",
"import": "Import a first file",
"categories": "Browse categories and keywords",
"manualCategory": "Categorize a transaction by hand",
"budget": "Enter a first budget"
"budget": "Enter a first budget",
"reports": "Browse the reports"
}
}
},

View file

@ -77,6 +77,7 @@
"card": {
"stepOf": "Étape {{n}} sur {{total}}",
"skip": "Passer cette étape",
"acknowledge": "J'ai compris",
"done": "Fait.",
"next": "Étape suivante : {{step}} →",
"import": {
@ -117,8 +118,10 @@
"importFolder": "Choisir un dossier d'import",
"source": "Configurer une source d'import",
"import": "Importer un premier fichier",
"categories": "Parcourir les catégories et les mots-clés",
"manualCategory": "Classer une transaction à la main",
"budget": "Saisir un premier budget"
"budget": "Saisir un premier budget",
"reports": "Parcourir les rapports"
}
}
},

View file

@ -78,13 +78,20 @@ function budget(overrides: Partial<BudgetState> = {}) {
/** 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 });
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/budget",
@ -92,6 +99,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -132,7 +140,7 @@ describe("BudgetPage — the step card (#400)", () => {
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.card.stepOf[n=7,total=8])");
expect(html).toContain("t(onboarding.checklist.items.budget)");
expect(html).toContain("t(onboarding.card.skip)");
// The grid is there, below the card.

View file

@ -63,13 +63,20 @@ const BUDGET_LEFT: OnboardingCounters = {
/** 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 });
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/budget",
@ -77,6 +84,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -172,6 +180,8 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
importFolderSet: true,
budgetAllowed: true,
skipped: ["source", "import", "manualCategory"],
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = ctx.budget as { state: BudgetState };

View file

@ -82,10 +82,16 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
const c = counters(transactions);
return {
status: "ready",
checklist: computeChecklist(c, { importFolderSet: true, budgetAllowed: true }),
checklist: computeChecklist(c, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: c.imports >= 1 ? ["categories", "reports"] : [],
}),
counters: c,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: null,
mode: "banner",
cardPage: null,
@ -93,6 +99,7 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...rest,
};

View file

@ -98,6 +98,7 @@ export default function DashboardPage() {
<DashboardEmptyState
checklist={checklist}
budgetReady={onboarding.budgetReady}
visitStepsInitialized={onboarding.visitStepsInitialized}
hidden={onboarding.hidden}
onHide={onboarding.hide}
onResume={onboarding.resume}

View file

@ -82,13 +82,20 @@ function path(
importFolderSet: boolean,
overrides: Partial<OnboardingValue> = {},
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
// A template stopped after the import has its visit steps acknowledged (#425).
const checklist = computeChecklist(counters, {
importFolderSet,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
@ -96,6 +103,7 @@ function path(
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -136,7 +144,7 @@ describe("ImportPage — the step card (#398)", () => {
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=2,total=6])");
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=8])");
});
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
@ -155,7 +163,7 @@ describe("ImportPage — the step card (#398)", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=8])");
expect(sentence(html)).toBe(
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
);
@ -217,7 +225,7 @@ describe("ImportPage — the step card (#398)", () => {
const html = render();
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
expect(count(html, HIGHLIGHT)).toBe(0);
});
@ -243,7 +251,7 @@ describe("ImportPage — the step card (#398)", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html);

View file

@ -59,13 +59,20 @@ function path(
overrides: Partial<OnboardingValue> = {},
budgetAllowed = true,
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/transactions",
@ -73,6 +80,7 @@ function path(
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
@ -152,7 +160,7 @@ describe("TransactionsPage — the step card (#399)", () => {
const html = render(GUIDED);
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=8])");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
});
@ -221,7 +229,7 @@ describe("TransactionsPage — the step card (#399)", () => {
it("in the Free edition, the step checked ends the path", () => {
harness.onboarding = path(CATEGORIZED, {}, false);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=7])");
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain('href="/budget"');

View file

@ -20,9 +20,13 @@ import { subscribeDataChanged } from "./dataEvents";
import {
ONBOARDING_CHECKLIST_HIDDEN_KEY,
ONBOARDING_GUIDED_INITIALIZED_KEY,
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
acknowledgeStep,
acknowledgedStepKey,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
initializeVisitSteps,
resumeStep,
showChecklist,
skipStep,
@ -66,6 +70,11 @@ function makeFakeDb(options: FakeDbOptions = {}) {
.filter(([key]) => /^onboarding.skipped./.test(key))
.map(([key, value]) => ({ key, value }));
}
if (sql.includes("LIKE 'onboarding_acknowledged_%'")) {
return Object.entries(prefs)
.filter(([key]) => /^onboarding.acknowledged./.test(key))
.map(([key, value]) => ({ key, value }));
}
if (sql.includes("FROM user_preferences WHERE key = $1")) {
const key = String(params?.[0]);
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
@ -125,10 +134,10 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
});
});
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
it("runs the five sub-counts in one query, plus the three preference reads and the skipped and acknowledged steps", async () => {
const { db } = useFakeDb();
await getOnboardingSnapshot();
expect(db.select).toHaveBeenCalledTimes(4);
expect(db.select).toHaveBeenCalledTimes(6);
const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
@ -138,9 +147,14 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
.filter((c) => String(c[0]).includes("WHERE key = $1"))
.map((c) => (c[1] as unknown[])[0]);
expect(prefKeys).toEqual(
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
expect.arrayContaining([
"import_folder",
ONBOARDING_CHECKLIST_HIDDEN_KEY,
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
]),
);
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_acknowledged_%'"))).toHaveLength(1);
});
// The tests run without SQLite, so the rule is pinned on the query itself.
@ -208,6 +222,40 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
});
});
it("reports no acknowledged step and visitStepsInitialized=false without preferences (#425)", async () => {
useFakeDb();
const snapshot = await getOnboardingSnapshot();
expect(snapshot.acknowledged).toEqual([]);
expect(snapshot.visitStepsInitialized).toBe(false);
});
it('reports visitStepsInitialized=true only for the value "1" (#425)', async () => {
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" } });
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(true);
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "true" } });
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
});
it("reads the acknowledged steps in checklist order, only the visit keys at \"1\" (#425)", async () => {
useFakeDb({
prefs: {
[acknowledgedStepKey("reports")]: "1",
[acknowledgedStepKey("categories")]: "1",
[acknowledgedStepKey("budget")]: "1",
onboarding_acknowledged_dragons: "1",
onboarding_acknowledged_reports_extra: "1",
},
});
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["categories", "reports"]);
useFakeDb({
prefs: {
[acknowledgedStepKey("categories")]: "0",
[acknowledgedStepKey("reports")]: "1",
},
});
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["reports"]);
});
it("reads the skipped steps in checklist order", async () => {
useFakeDb({
prefs: {
@ -218,7 +266,7 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]);
});
it("keeps only the six keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
it("keeps only the eight keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
useFakeDb({
prefs: {
onboarding_skipped_dragons: "1",
@ -468,3 +516,97 @@ describe("skipStep / resumeStep (#396, D3)", () => {
expect(listener).not.toHaveBeenCalled();
});
});
describe("acknowledgeStep / initializeVisitSteps (#425)", () => {
it('acknowledgeStep writes "1" under onboarding_acknowledged_<key>, without reading first', async () => {
const fake = useFakeDb();
await acknowledgeStep("categories");
expect(fake.db.select).not.toHaveBeenCalled();
expect(fake.db.execute).toHaveBeenCalledTimes(1);
expect(fake.db.execute.mock.calls[0][1]).toEqual(["onboarding_acknowledged_categories", "1"]);
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
});
it("acknowledgeStep round-trips through the snapshot, as a done step, not a skipped one", async () => {
useFakeDb();
await acknowledgeStep("reports");
const snapshot = await getOnboardingSnapshot();
expect(snapshot.acknowledged).toEqual(["reports"]);
expect(snapshot.skipped).toEqual([]);
});
it("initializeVisitSteps(true) writes both acknowledgements, then the flag", async () => {
const fake = useFakeDb();
await initializeVisitSteps(true);
expect(fake.db.execute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([
"onboarding_acknowledged_categories",
"onboarding_acknowledged_reports",
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
]);
expect(fake.prefs()).toEqual({
onboarding_acknowledged_categories: "1",
onboarding_acknowledged_reports: "1",
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
});
const snapshot = await getOnboardingSnapshot();
expect(snapshot.acknowledged).toEqual(["categories", "reports"]);
expect(snapshot.visitStepsInitialized).toBe(true);
});
it("initializeVisitSteps(false) writes the flag only", async () => {
const fake = useFakeDb();
await initializeVisitSteps(false);
expect(fake.prefs()).toEqual({ [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" });
expect(logInfo).not.toHaveBeenCalled();
});
it("runs without any transaction: no withTransaction, no BEGIN (D16)", async () => {
const fake = useFakeDb();
await initializeVisitSteps(true);
await initializeVisitSteps(false);
expect(withTransaction).not.toHaveBeenCalled();
expect(fake.statements.filter((sql) => /^(BEGIN|COMMIT|ROLLBACK)/.test(sql))).toEqual([]);
});
it("a failed acknowledgement leaves the flag unset, so the next opening computes again", async () => {
const fake = useFakeDb({
failOn: (_sql, params) => params[0] === "onboarding_acknowledged_reports",
});
await expect(initializeVisitSteps(true)).rejects.toThrow("database is locked");
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
});
it("is idempotent: run again after a stop, it ends in the same state", async () => {
const fake = useFakeDb({ prefs: { onboarding_acknowledged_categories: "1" } });
await initializeVisitSteps(true);
await initializeVisitSteps(true);
expect(fake.prefs()).toEqual({
onboarding_acknowledged_categories: "1",
onboarding_acknowledged_reports: "1",
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
});
});
it("logs a fixed line only when it marks the visit steps done", async () => {
useFakeDb();
await initializeVisitSteps(true);
expect(vi.mocked(logInfo).mock.calls.map((c) => c[0])).toContain(
"Onboarding: the path was already complete, visit steps marked done",
);
});
it("neither announces on the data channel", async () => {
useFakeDb();
const listener = vi.fn();
const unsubscribe = subscribeDataChanged(listener);
try {
await acknowledgeStep("categories");
await initializeVisitSteps(true);
await initializeVisitSteps(false);
} finally {
unsubscribe();
}
expect(listener).not.toHaveBeenCalled();
});
});

View file

@ -8,6 +8,7 @@ import {
import { logInfo } from "./logService";
import {
CHECKLIST_ITEM_KEYS,
VISIT_STEP_KEYS,
type ChecklistItemKey,
type OnboardingCounters,
} from "../shared/types";
@ -17,9 +18,11 @@ import {
*
* `getOnboardingSnapshot` reads everything the path needs in one round of
* parallel reads, and writes nothing: the counters, the import folder, the
* "hidden" preference and the skipped steps. The writes are separate, and the
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
* "hidden" preference, the skipped and acknowledged steps, and the visit-steps
* flag. The writes are separate, and the onboarding provider is their only
* caller: `initializeGuidedPath` (rule D9), `initializeVisitSteps` (#425),
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`,
* `acknowledgeStep`. None of them is
* announced on the data channel: the provider updates its own state. No UI
* lives here; `computeChecklist` turns the snapshot into rows and
* `useOnboardingChecklist` holds the state.
@ -36,14 +39,26 @@ export function skippedStepKey(key: ChecklistItemKey): string {
return `onboarding_skipped_${key}`;
}
/** `user_preferences` key of a visit step done by "Got it" (#425): `onboarding_acknowledged_<key>`, value "1". */
export function acknowledgedStepKey(key: ChecklistItemKey): string {
return `onboarding_acknowledged_${key}`;
}
/** `user_preferences` key set to "1" once the visit steps were initialized for the profile (#425). */
export const ONBOARDING_VISIT_STEPS_INITIALIZED_KEY = "onboarding_visit_steps_initialized";
export interface OnboardingSnapshot {
counters: OnboardingCounters;
/** True when an import folder has been chosen. */
importFolderSet: boolean;
/** True when the path is hidden (by the user, or by rule D9). */
hidden: boolean;
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
/** Skipped steps, in checklist order: only the eight keys, only the value "1". */
skipped: ChecklistItemKey[];
/** Visit steps done by "Got it" (#425), in checklist order: only the visit keys, only the value "1". */
acknowledged: ChecklistItemKey[];
/** True once `initializeVisitSteps` ran for the profile (only the value "1" counts). */
visitStepsInitialized: boolean;
}
interface CountersRow {
@ -74,6 +89,10 @@ const COUNTERS_SQL = `
const SKIPPED_SQL =
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
// Same caveat: `readAcknowledgedSteps` keeps only the exact keys of the visit steps.
const ACKNOWLEDGED_SQL =
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_acknowledged_%'";
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
@ -99,23 +118,37 @@ async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
}
/** Acknowledged visit steps, read in one query and kept only for a visit key holding "1". */
async function readAcknowledgedSteps(): Promise<ChecklistItemKey[]> {
const db = await getDb();
const rows = await db.select<Array<{ key: string; value: string }>>(ACKNOWLEDGED_SQL);
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
return VISIT_STEP_KEYS.filter((key) => marked.has(acknowledgedStepKey(key)));
}
/**
* Reads the counters, the import folder, the hidden flag and the skipped steps
* in parallel. A pure read: it never writes (rule D9 lives in
* `initializeGuidedPath`).
* Reads the counters, the import folder, the hidden flag, the skipped and
* acknowledged steps and the visit-steps flag in parallel. A pure read: it
* never writes (rule D9 lives in `initializeGuidedPath`, the visit steps'
* initialization in `initializeVisitSteps`).
*/
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
const [counters, importFolder, hiddenPref, skipped, acknowledged, visitFlag] =
await Promise.all([
readCounters(),
getImportFolder(),
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
readSkippedSteps(),
readAcknowledgedSteps(),
getPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY),
]);
return {
counters,
importFolderSet: importFolder !== null && importFolder !== "",
hidden: hiddenPref === "1",
skipped,
acknowledged,
visitStepsInitialized: visitFlag === "1",
};
}
@ -201,3 +234,34 @@ export async function resumeStep(key: ChecklistItemKey): Promise<void> {
await deletePreference(skippedStepKey(key));
logInfo(`Onboarding: step ${key} resumed`);
}
/** Marks a visit step done ("Got it", #425). Idempotent: an UPSERT, no read before the write. */
export async function acknowledgeStep(key: ChecklistItemKey): Promise<void> {
await setPreference(acknowledgedStepKey(key), "1");
logInfo(`Onboarding: step ${key} acknowledged`);
}
/**
* The visit steps' initialization (#425), once per profile: the provider calls
* it after the first successful load where the Budget entitlement is known and
* the flag is unset, with `acknowledge` = `shouldAcknowledgeVisitSteps`, then
* re-reads. A profile whose other steps were all done or skipped gets both
* visit steps acknowledged, so it keeps "Getting started complete".
*
* Deliberately without a transaction (D16): under the strict lock of `db.ts`
* (#408), a service call made from a transaction's callback would wait behind
* that very lock, and every write here is idempotent anyway. The two
* acknowledgements come first, then the flag: a stop between two writes leaves
* the flag unset, and the next opening makes the same computation again.
* Not announced on the data channel.
*/
export async function initializeVisitSteps(acknowledge: boolean): Promise<void> {
if (acknowledge) {
await acknowledgeStep("categories");
await acknowledgeStep("reports");
}
await setPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY, "1");
if (acknowledge) {
logInfo("Onboarding: the path was already complete, visit steps marked done");
}
}

View file

@ -982,19 +982,30 @@ export type ChecklistItemKey =
| "importFolder"
| "source"
| "import"
| "categories"
| "manualCategory"
| "budget";
| "budget"
| "reports";
/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
/** The eight checklist keys, in checklist order (#396: the only valid skipped-step keys; #425: eight). */
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
];
/**
* The visit steps (#425): nothing in the database counts them, they are done
* by "Got it" on their card (`onboarding_acknowledged_<key>`), and the only
* valid acknowledged-step keys.
*/
export const VISIT_STEP_KEYS: readonly ChecklistItemKey[] = ["categories", "reports"];
/** One row of the getting-started checklist. `to` is the route the row links to. */
export interface ChecklistItem {
key: ChecklistItemKey;
@ -1002,7 +1013,10 @@ export interface ChecklistItem {
done: boolean;
/** The user skipped the step (#396, D3). A row can be done and skipped. */
skipped: boolean;
/** True when the feature behind the row is not part of the current edition. */
/**
* True when the feature behind the row is not part of the current edition,
* or for a visit step (#425) until the profile's visit steps are initialized.
*/
locked: boolean;
to: string;
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { computeChecklist } from "./onboardingChecklist";
import { computeChecklist, shouldAcknowledgeVisitSteps } from "./onboardingChecklist";
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
const zero: OnboardingCounters = {
@ -10,7 +10,9 @@ const zero: OnboardingCounters = {
transactions: 0,
};
const open = { importFolderSet: false, budgetAllowed: true };
const open = { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true };
/** Both visit steps acknowledged (#425): the old six-step fixtures, complete again at 8/8. */
const visited: ChecklistItemKey[] = ["categories", "reports"];
function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
return computeChecklist(counters, opts).items.map((i) => i.key);
@ -23,22 +25,24 @@ function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[]
}
describe("computeChecklist — rows (#351)", () => {
it("lists the six rows in order", () => {
it("lists the eight rows in order (#425)", () => {
expect(keys(zero)).toEqual([
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
]);
});
it("zero counters → only profile is done (1/6)", () => {
it("zero counters → only profile is done (1/8)", () => {
const list = computeChecklist(zero, open);
expect(doneKeys(zero)).toEqual(["profile"]);
expect(list.done).toBe(1);
expect(list.total).toBe(6);
expect(list.total).toBe(8);
expect(list.complete).toBe(false);
});
@ -79,8 +83,10 @@ describe("computeChecklist — rows (#351)", () => {
importFolder: "/import",
source: "/import",
import: "/import",
categories: "/categories",
manualCategory: "/transactions",
budget: "/budget",
reports: "/reports",
});
});
@ -90,11 +96,11 @@ describe("computeChecklist — rows (#351)", () => {
});
describe("computeChecklist — budget gating (#351)", () => {
const locked = { importFolderSet: false, budgetAllowed: false };
const locked = { importFolderSet: false, budgetAllowed: false, visitStepsInitialized: true };
it("budgetAllowed=false → total=5 and the budget row is locked", () => {
it("budgetAllowed=false → total=7 and the budget row is locked", () => {
const list = computeChecklist(zero, locked);
expect(list.total).toBe(5);
expect(list.total).toBe(7);
const budget = list.items.find((i) => i.key === "budget")!;
expect(budget.locked).toBe(true);
expect(budget.to).toBe("/settings/users");
@ -106,27 +112,27 @@ describe("computeChecklist — budget gating (#351)", () => {
expect(list.done).toBe(1);
});
it("complete is true at 5/5 when Budget is locked", () => {
it("complete is true at 7/7 when Budget is locked", () => {
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
const list = computeChecklist(all, { ...locked, importFolderSet: true });
expect(list.done).toBe(5);
expect(list.total).toBe(5);
const list = computeChecklist(all, { ...locked, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(7);
expect(list.total).toBe(7);
expect(list.complete).toBe(true);
});
it("complete is false at 5/6 when Budget is open but empty", () => {
it("complete is false at 7/8 when Budget is open but empty", () => {
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
const list = computeChecklist(all, { ...open, importFolderSet: true });
expect(list.done).toBe(5);
expect(list.total).toBe(6);
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(7);
expect(list.total).toBe(8);
expect(list.complete).toBe(false);
});
it("complete is true at 6/6 when Budget is open", () => {
it("complete is true at 8/8 when Budget is open", () => {
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
const list = computeChecklist(all, { ...open, importFolderSet: true });
expect(list.done).toBe(6);
expect(list.total).toBe(6);
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(8);
expect(list.total).toBe(8);
expect(list.complete).toBe(true);
});
});
@ -147,21 +153,31 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
expect(row.skipped).toBe(true);
expect(row.done).toBe(false);
expect(list.done).toBe(2); // profile + the skipped row
expect(list.total).toBe(6);
expect(list.total).toBe(8);
});
it("complete with done and skipped rows", () => {
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
expect(list.done).toBe(6);
expect(list.total).toBe(6);
const list = computeChecklist(fiveDone, {
...open,
importFolderSet: true,
skipped: ["budget"],
acknowledged: visited,
});
expect(list.done).toBe(8);
expect(list.total).toBe(8);
expect(list.complete).toBe(true);
});
it("a locked row stays out of the total, even skipped", () => {
const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
const list = computeChecklist(zero, {
importFolderSet: false,
budgetAllowed: false,
visitStepsInitialized: true,
skipped: ["budget"],
});
const budget = list.items.find((i) => i.key === "budget")!;
expect(budget.locked).toBe(true);
expect(list.total).toBe(5);
expect(list.total).toBe(7);
expect(list.done).toBe(1);
expect(list.complete).toBe(false);
});
@ -171,12 +187,13 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
...open,
importFolderSet: true,
skipped: ["source", "import", "budget"],
acknowledged: visited,
});
const source = list.items.find((i) => i.key === "source")!;
expect(source.done).toBe(true);
expect(source.skipped).toBe(true);
expect(list.done).toBe(6);
expect(list.total).toBe(6);
expect(list.done).toBe(8);
expect(list.total).toBe(8);
});
it("skipping does not change the rows' links", () => {
@ -186,3 +203,115 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
});
});
describe("computeChecklist — visit steps (#425)", () => {
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
const row = (list: ReturnType<typeof computeChecklist>, key: ChecklistItemKey) =>
list.items.find((i) => i.key === key)!;
it("a visit step is to do until acknowledged; nothing in the counters marks it done", () => {
const list = computeChecklist(
{ ...sixDone, transactions: 50 },
{ ...open, importFolderSet: true },
);
expect(row(list, "categories").done).toBe(false);
expect(row(list, "reports").done).toBe(false);
expect(list.done).toBe(6);
expect(list.total).toBe(8);
expect(list.complete).toBe(false);
});
it("an acknowledged step is done, not skipped", () => {
const list = computeChecklist(zero, { ...open, acknowledged: ["categories"] });
expect(row(list, "categories").done).toBe(true);
expect(row(list, "categories").skipped).toBe(false);
expect(row(list, "reports").done).toBe(false);
expect(list.done).toBe(2);
});
it("only a visit step can be acknowledged: another key in the option changes nothing", () => {
const list = computeChecklist(zero, { ...open, acknowledged: ["budget", "source"] });
expect(row(list, "budget").done).toBe(false);
expect(row(list, "source").done).toBe(false);
expect(list.done).toBe(1);
});
it("a visit step can be skipped", () => {
const list = computeChecklist(zero, { ...open, skipped: ["reports"] });
expect(row(list, "reports").skipped).toBe(true);
expect(row(list, "reports").done).toBe(false);
expect(list.done).toBe(2);
});
it("before the initialization, both visit rows are locked: out of the total and of `complete`, links kept", () => {
const list = computeChecklist(sixDone, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: false,
});
expect(row(list, "categories").locked).toBe(true);
expect(row(list, "reports").locked).toBe(true);
expect(row(list, "categories").to).toBe("/categories");
expect(row(list, "reports").to).toBe("/reports");
expect(list.items.filter((i) => i.locked).map((i) => i.key)).toEqual(["categories", "reports"]);
expect(list.total).toBe(6);
expect(list.done).toBe(6);
expect(list.complete).toBe(true);
});
it("before the initialization, a visit row is out of the total even when acknowledged or skipped", () => {
const list = computeChecklist(zero, {
importFolderSet: false,
budgetAllowed: false,
visitStepsInitialized: false,
acknowledged: ["categories"],
skipped: ["reports"],
});
expect(list.total).toBe(5);
expect(list.done).toBe(1);
});
});
describe("shouldAcknowledgeVisitSteps (#425)", () => {
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
const before = { importFolderSet: true, budgetAllowed: true, visitStepsInitialized: false };
it("true when the six other rows are done (Base+)", () => {
expect(shouldAcknowledgeVisitSteps(computeChecklist(sixDone, before))).toBe(true);
});
it("true when the six other rows are done or skipped", () => {
const counters = { ...zero, sources: 1, imports: 1 };
const list = computeChecklist(counters, { ...before, skipped: ["manualCategory", "budget"] });
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
});
it("true in the Free edition with Budget locked and the five others done", () => {
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
const list = computeChecklist(counters, { ...before, budgetAllowed: false });
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
});
it("false while one row is left", () => {
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
expect(shouldAcknowledgeVisitSteps(computeChecklist(counters, before))).toBe(false);
expect(
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, importFolderSet: false })),
).toBe(false);
});
it("the visit rows do not enter the computation, locked or not", () => {
// Unlocked and to do: still true.
expect(
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, visitStepsInitialized: true })),
).toBe(true);
// Acknowledged or skipped: does not make an incomplete path complete.
const list = computeChecklist(zero, {
...before,
visitStepsInitialized: true,
acknowledged: ["categories"],
skipped: ["reports"],
});
expect(shouldAcknowledgeVisitSteps(list)).toBe(false);
});
});

View file

@ -1,13 +1,24 @@
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
import {
VISIT_STEP_KEYS,
type ChecklistItem,
type ChecklistItemKey,
type OnboardingCounters,
} from "../shared/types";
/**
* Pure computation of the getting-started checklist (#351).
* Pure computation of the getting-started checklist (#351, #425).
*
* Six rows in a fixed order. The `profile` row is always done (the checklist
* Eight rows in a fixed order. The `profile` row is always done (the checklist
* is only reachable from an open profile). The `budget` row is locked when the
* edition does not include Budget; a locked row is excluded from `total` and
* from `complete`, and links to the Users settings (license card) instead.
*
* The two visit steps, `categories` and `reports` (#425), are done when the
* user acknowledged them ("Got it"): nothing in the database counts them.
* Until the profile's visit steps are initialized (`visitStepsInitialized`),
* they are locked the same way, so a profile whose path was complete does not
* flash a "Next step" before they are marked done; their `to` stays the page.
*
* A row can also be skipped by the user (#396, D3). A skipped row counts like a
* done one, once even when it is both, unless it is locked: a locked row stays
* out of the total whatever its state.
@ -20,13 +31,17 @@ export interface ChecklistOptions {
budgetAllowed: boolean;
/** Steps the user skipped (#396). None when omitted. */
skipped?: readonly ChecklistItemKey[];
/** Visit steps the user acknowledged with "Got it" (#425). None when omitted. */
acknowledged?: readonly ChecklistItemKey[];
/** The profile's visit steps are initialized (#425): until then, they are locked. */
visitStepsInitialized: boolean;
}
export interface Checklist {
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; 2 fewer before the visit steps are initialized). */
total: number;
/** True when every unlocked row is done or skipped. */
complete: boolean;
@ -37,7 +52,9 @@ export function computeChecklist(
options: ChecklistOptions
): Checklist {
const budgetLocked = !options.budgetAllowed;
const visitLocked = !options.visitStepsInitialized;
const skipped = new Set(options.skipped ?? []);
const acknowledged = new Set(options.acknowledged ?? []);
const row = (
key: ChecklistItemKey,
done: boolean,
@ -52,6 +69,7 @@ export function computeChecklist(
row("importFolder", options.importFolderSet, "/import"),
row("source", counters.sources >= 1, "/import"),
row("import", counters.imports >= 1, "/import"),
row("categories", acknowledged.has("categories"), "/categories", visitLocked),
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
row(
"budget",
@ -59,6 +77,7 @@ export function computeChecklist(
budgetLocked ? "/settings/users" : "/budget",
budgetLocked,
),
row("reports", acknowledged.has("reports"), "/reports", visitLocked),
];
const unlocked = items.filter((item) => !item.locked);
@ -67,3 +86,15 @@ export function computeChecklist(
return { items, done, total, complete: done === total };
}
/**
* Whether the one-time initialization of the visit steps (#425) marks them
* done: true when every unlocked row other than the visit steps is done or
* skipped, so a profile that had finished the path before they existed keeps
* "Getting started complete". The visit rows themselves do not count.
*/
export function shouldAcknowledgeVisitSteps(checklist: Checklist): boolean {
return checklist.items
.filter((item) => !item.locked && !VISIT_STEP_KEYS.includes(item.key))
.every((item) => item.done || item.skipped);
}

View file

@ -47,14 +47,27 @@ const upToManual: OnboardingCounters = {
transactions: 20,
};
/**
* The checklist of `counters`. The visit steps are acknowledged and initialized
* unless told otherwise (rule of the templates, #425): the fixtures below are
* about the six data steps.
*/
const checklistOf = (
counters: OnboardingCounters,
options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {},
options: {
importFolderSet?: boolean;
budgetAllowed?: boolean;
skipped?: ChecklistItemKey[];
acknowledged?: ChecklistItemKey[];
visitStepsInitialized?: boolean;
} = {},
): Checklist =>
computeChecklist(counters, {
importFolderSet: options.importFolderSet ?? false,
budgetAllowed: options.budgetAllowed ?? true,
skipped: options.skipped,
acknowledged: options.acknowledged ?? ["categories", "reports"],
visitStepsInitialized: options.visitStepsInitialized ?? true,
});
/** A row of the given step, as `computeChecklist` builds it. */
@ -112,6 +125,22 @@ describe("nextStep (#396)", () => {
expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
.toMatchObject({ key: "manualCategory", to: "/transactions" });
});
it("leads to the visit steps once initialized and not acknowledged (#425)", () => {
const imported = { ...zero, sources: 1, imports: 1 };
expect(nextStep(checklistOf(imported, { importFolderSet: true, acknowledged: [] })))
.toMatchObject({ key: "categories", to: "/categories" });
const allButReports = { ...upToManual, budgetEntries: 1 };
expect(
nextStep(checklistOf(allButReports, { importFolderSet: true, acknowledged: ["categories"] })),
).toMatchObject({ key: "reports", to: "/reports" });
// Before the initialization, they are passed over.
expect(
nextStep(
checklistOf(imported, { importFolderSet: true, acknowledged: [], visitStepsInitialized: false }),
)?.key,
).toBe("manualCategory");
});
});
describe("stepsOfPage (#396)", () => {
@ -286,7 +315,7 @@ describe("bannerMode (#396)", () => {
});
it("waits for the Budget entitlement before announcing the end", () => {
// Until the license is known, Budget counts as locked: 5/5 could be 5/6.
// Until the license is known, Budget counts as locked: 7/7 could be 7/8.
const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
expect(failClosed.complete).toBe(true);
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
@ -296,6 +325,17 @@ describe("bannerMode (#396)", () => {
it("shows the banner before the Budget entitlement is known when steps remain", () => {
expect(mode({ budgetReady: false })).toBe("banner");
});
it("before the visit steps' initialization, a complete path is complete without them (#425)", () => {
// The provider passes `budgetReady && visitStepsInitialized`: until then, no end.
const beforeInit = checklistOf(
{ ...upToManual, budgetEntries: 1 },
{ importFolderSet: true, acknowledged: [], visitStepsInitialized: false },
);
expect(beforeInit.complete).toBe(true);
expect(nextStep(beforeInit)).toBeNull();
expect(mode({ checklist: beforeInit, budgetReady: false })).toBe("none");
});
});
describe("readOnboardingNavState (#396)", () => {
@ -391,12 +431,12 @@ describe("cardStep (#398)", () => {
};
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 });
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 });
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 8 });
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 8 });
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
key: "import",
n: 4,
total: 6,
total: 8,
});
});
@ -404,7 +444,7 @@ describe("cardStep (#398)", () => {
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
key: "import",
n: 4,
total: 6,
total: 8,
});
});
@ -418,17 +458,23 @@ describe("cardStep (#398)", () => {
);
});
it("counts the unlocked rows only: five in the Free edition", () => {
it("counts the unlocked rows only: seven in the Free edition", () => {
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
key: "importFolder",
n: 2,
total: 5,
total: 7,
});
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
key: "manualCategory",
n: 5,
total: 6,
n: 6,
total: 8,
});
expect(at(checklistOf(zero), "/budget")).toEqual({ key: "budget", n: 7, total: 8 });
});
it("ranks the steps without the visit steps until they are initialized (#425)", () => {
const beforeInit = checklistOf(upToManual, { importFolderSet: true, visitStepsInitialized: false });
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
});
it("is null on a page that guides no unlocked step", () => {

View file

@ -88,7 +88,7 @@ export interface BannerModeInput {
pathname: string;
/** The dashboard of a profile without any transaction (D11). */
isEmptyDashboard: boolean;
/** False until the Budget entitlement is known. */
/** False until the Budget entitlement is known (and, from the provider, the visit steps initialized, #425). */
budgetReady: boolean;
/** The latched card page (`latchCardPage`). */
cardPage: string | null;
@ -102,7 +102,9 @@ export interface BannerModeInput {
* then says so itself);
* - `complete` when every unlocked step is done or skipped (D4), and only once
* Budget's entitlement is known: until then the checklist is computed with
* Budget locked, and "complete" could be a sixth step away;
* Budget locked, and "complete" could be one step away; the provider passes
* `budgetReady && visitStepsInitialized` as `budgetReady` (#425), the visit
* steps being locked the same way until their initialization;
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
*
* A failed re-read after a first success keeps status `ready` (reducer), so
@ -209,7 +211,8 @@ export type StepCardNext =
* - `step`: the next step, which lives on another page;
* - `end`: no step left (end mode), once the Budget entitlement is known:
* until then the checklist counts Budget as locked, and the end could be a
* sixth step away (the rule of `bannerMode`);
* step away (the rule of `bannerMode`); the caller passes
* `budgetReady && visitStepsInitialized` (#425);
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
* while the next step is still one of this page's own: the re-read that
* follows the write that completed them (ADR 0022) has not landed, and a