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( const checklist = computeChecklist(
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 }, { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
{ importFolderSet: false, budgetAllowed: true }, { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
); );
function cardProps(hidden = false) { 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) => const markup = (hidden = false) =>
@ -36,7 +43,7 @@ describe("DashboardEmptyState (#352, #397)", () => {
expect(html).toContain("t(dashboard.empty.description)"); expect(html).toContain("t(dashboard.empty.description)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/); expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
expect(html).toContain('aria-label="t(onboarding.checklist.title)"'); 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", () => { 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.type).toBe(OnboardingChecklist);
expect(list.props.items).toBe(checklist.items); expect(list.props.items).toBe(checklist.items);
expect(list.props.budgetReady).toBe(true); 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.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume); expect(list.props.onResume).toBe(props.onResume);
}); });

View file

@ -8,6 +8,8 @@ import type { ChecklistItemKey } from "../../shared/types";
interface DashboardEmptyStateProps { interface DashboardEmptyStateProps {
checklist: Checklist; checklist: Checklist;
budgetReady: boolean; 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. */ /** The user dismissed the checklist: the welcome card stays, the list goes. */
hidden: boolean; hidden: boolean;
onHide: () => void; onHide: () => void;
@ -24,6 +26,7 @@ interface DashboardEmptyStateProps {
export default function DashboardEmptyState({ export default function DashboardEmptyState({
checklist, checklist,
budgetReady, budgetReady,
visitStepsInitialized,
hidden, hidden,
onHide, onHide,
onResume, onResume,
@ -43,6 +46,7 @@ export default function DashboardEmptyState({
done={checklist.done} done={checklist.done}
total={checklist.total} total={checklist.total}
budgetReady={budgetReady} budgetReady={budgetReady}
visitStepsInitialized={visitStepsInitialized}
onHide={onHide} onHide={onHide}
onResume={onResume} onResume={onResume}
/> />

View file

@ -67,8 +67,14 @@ function render(
counters = ZERO, counters = ZERO,
budgetAllowed = true, budgetAllowed = true,
skipped: ChecklistItemKey[] = [], 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 onHide = vi.fn();
const onResume = vi.fn(); const onResume = vi.fn();
const props: OnboardingChecklistProps = { const props: OnboardingChecklistProps = {
@ -76,6 +82,7 @@ function render(
done: checklist.done, done: checklist.done,
total: checklist.total, total: checklist.total,
budgetReady: true, budgetReady: true,
visitStepsInitialized,
onHide, onHide,
onResume, onResume,
...overrides, ...overrides,
@ -97,12 +104,12 @@ function stateTextsOf(row: AnyElement): string[] {
} }
describe("OnboardingChecklist", () => { 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 { element, onHide } = render();
const text = texts(element); const text = texts(element);
expect(text).toContain("t(onboarding.checklist.title)"); expect(text).toContain("t(onboarding.checklist.title)");
expect((element as AnyElement).props["aria-label"]).toBe("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"); const [hide] = find(element, (e) => e.type === "button");
expect(hide.props.type).toBe("button"); expect(hide.props.type).toBe("button");
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)"); expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
@ -110,11 +117,22 @@ describe("OnboardingChecklist", () => {
expect(onHide).toHaveBeenCalledTimes(1); 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(); 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); 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})`); expect(text).toContain(`t(onboarding.checklist.items.${key})`);
} }
}); });
@ -154,7 +172,7 @@ describe("OnboardingChecklist", () => {
expect(link.props.to).toBe("/settings/users"); expect(link.props.to).toBe("/settings/users");
// The license card carries no step card: nothing to focus there. // The license card carries no step card: nothing to focus there.
expect(link.props.state).toBeUndefined(); 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", () => { 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. // The hidden "(skipped)" text already says it: the badge is not read twice.
expect(badge.props["aria-hidden"]).toBe("true"); 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", () => { 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"); 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 { Link } from "react-router-dom";
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react"; import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import { stepNavState } from "../../utils/onboardingPath"; 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 { export interface OnboardingChecklistProps {
items: ChecklistItem[]; items: ChecklistItem[];
/** Number of unlocked rows that are done or skipped. */ /** Number of unlocked rows that are done or skipped. */
done: number; 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; total: number;
/** /**
* False until the Budget entitlement is known. The budget row is then * False until the Budget entitlement is known. The budget row is then
@ -16,6 +16,12 @@ export interface OnboardingChecklistProps {
* `total` depends on the lock. * `total` depends on the lock.
*/ */
budgetReady: boolean; 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; onHide: () => void;
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */ /** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
onResume: (key: ChecklistItemKey) => void; 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 * done shows as done even if it was skipped too: "Resume" would bring back
* nothing to do. * 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 (item.key === "budget" && !budgetReady) return "pending";
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
if (item.locked) return "locked"; if (item.locked) return "locked";
if (item.done) return "done"; if (item.done) return "done";
return item.skipped ? "skipped" : "todo"; return item.skipped ? "skipped" : "todo";
@ -75,6 +86,7 @@ export default function OnboardingChecklist({
done, done,
total, total,
budgetReady, budgetReady,
visitStepsInitialized,
onHide, onHide,
onResume, onResume,
}: OnboardingChecklistProps) { }: OnboardingChecklistProps) {
@ -88,7 +100,7 @@ export default function OnboardingChecklist({
> >
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3> <h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{budgetReady && ( {budgetReady && visitStepsInitialized && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums"> <span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })} {t("onboarding.checklist.counter", { done, total })}
</span> </span>
@ -105,7 +117,7 @@ export default function OnboardingChecklist({
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{items.map((item) => { {items.map((item) => {
const state = rowState(item, budgetReady); const state = rowState(item, budgetReady, visitStepsInitialized);
const label = t(`onboarding.checklist.items.${item.key}`); const label = t(`onboarding.checklist.items.${item.key}`);
const stateText = const stateText =
state === "todo" ? null : ( state === "todo" ? null : (

View file

@ -39,12 +39,19 @@ const NEW_PROFILE: OnboardingSnapshot = {
importFolderSet: false, importFolderSet: false,
hidden: false, hidden: false,
skipped: [], 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 = { const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE, ...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 }, counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true, importFolderSet: true,
acknowledged: ["categories", "reports"],
}; };
/** Everything but the budget: the next step is on /budget. */ /** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = { const BUDGET_LEFT: OnboardingSnapshot = {
@ -75,6 +82,8 @@ function pathAt(
importFolderSet: state.importFolderSet, importFolderSet: state.importFolderSet,
budgetAllowed, budgetAllowed,
skipped: state.skipped, skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
}) })
: null; : null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady }); const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
@ -84,6 +93,7 @@ function pathAt(
counters: state.counters, counters: state.counters,
hidden: state.hidden, hidden: state.hidden,
budgetReady, budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep, nextStep,
mode, mode,
cardPage, cardPage,
@ -91,6 +101,7 @@ function pathAt(
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: 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"'); 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); 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.nextStep).toBeNull();
expect(value.cardPage).toBeNull(); expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete"); expect(value.mode).toBe("complete");
@ -209,10 +220,19 @@ describe("AppShell — the getting-started banner (#397)", () => {
expect(waiting).toContain(BANNER); expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress"); expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain( 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", () => { it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED)); const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"'); expect(html).toContain('aria-expanded="false"');

View file

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

View file

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

View file

@ -44,7 +44,7 @@ const ZERO: OnboardingCounters = {
budgetEntries: 0, budgetEntries: 0,
transactions: 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 }; const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */ /** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 }; const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
@ -60,12 +60,19 @@ function bannerProps(
overrides: Partial<OnboardingBannerProps> = {}, overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE, counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps { ): 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 { return {
mode: checklist.complete ? "complete" : "banner", mode: checklist.complete ? "complete" : "banner",
checklist, checklist,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
listOpen: false, listOpen: false,
onToggleList: vi.fn(), onToggleList: vi.fn(),
onHide: vi.fn(), onHide: vi.fn(),
@ -94,7 +101,7 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const html = markup(bannerProps()); const html = markup(bannerProps());
expect(html).toContain('role="region"'); expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"'); 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).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/); expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)"); expect(html).toContain("t(onboarding.checklist.hide)");
@ -117,6 +124,12 @@ describe("OnboardingBanner — banner mode (#397)", () => {
expect(html).toMatch(/href="\/import"/); 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", () => { it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps()); const html = markup(bannerProps());
expect(html).toMatch( expect(html).toMatch(
@ -134,7 +147,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`); const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1); expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container); 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})`); 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); const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items); expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3); 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.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.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume); expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0); expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);

View file

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

View file

@ -21,6 +21,21 @@ vi.mock("react-router-dom", async (importOriginal) => ({
useNavigate: () => ctx.navigate, useNavigate: () => ctx.navigate,
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding })); 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 { hookHarness } from "../../test/hookHarness";
import OnboardingStepCard from "./OnboardingStepCard"; 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. */ /** The path latched on /import, as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue { 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 { return {
status: "ready", status: "ready",
checklist, checklist,
counters, counters,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
mode: "card", mode: "card",
cardPage: "/import", cardPage: "/import",
@ -60,6 +82,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...overrides, ...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", () => { it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
const props = render()!.props; const props = render()!.props;
expect(props.step.key).toBe("source"); 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.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false); expect(props.done).toBe(false);
expect(props.titleRef).toBe(titleRef()); expect(props.titleRef).toBe(titleRef());
@ -147,6 +170,39 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(render()!.props.position).toBeNull(); 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", () => { it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = ctx.onboarding as OnboardingValue; const value = ctx.onboarding as OnboardingValue;
const props = render()!.props; const props = render()!.props;

View file

@ -9,6 +9,7 @@ import {
showsStepCard, showsStepCard,
type StepCardModel, type StepCardModel,
} from "../../utils/onboardingPath"; } from "../../utils/onboardingPath";
import { VISIT_STEP_KEYS } from "../../shared/types";
export interface OnboardingStepCardProps { export interface OnboardingStepCardProps {
/** The page's card model; null for no card yet (the page's own data is not read). */ /** 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 * 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 * 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. * 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 * - It wires "Skip this step" to the step it shows, "Got it" to
* the next step's link to the path's next step. * `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) { export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
const onboarding = useOnboarding(); const onboarding = useOnboarding();
@ -55,15 +59,18 @@ export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
if (!shown) return null; 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 ( return (
<StepCard <StepCard
step={current.step} 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} text={model.text}
done={model.done} done={model.done}
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)} next={cardNext(onboarding.nextStep, pathname, totalKnown)}
onSkip={() => void skip(current.step.key)} onSkip={() => void skip(current.step.key)}
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
onHide={() => void hide()} onHide={() => void hide()}
titleRef={titleRef} titleRef={titleRef}
/> />

View file

@ -40,15 +40,19 @@ const ZERO: OnboardingCounters = {
transactions: 0, transactions: 0,
}; };
const rows = (counters: OnboardingCounters = ZERO) => 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 => const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
rows(counters).find((item) => item.key === key)!; 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 { function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
return { return {
step: row("source"), step: row("source"),
position: { n: 3, total: 6 }, position: { n: 3, total: 8 },
text: { key: "onboarding.card.import.sourceList" }, text: { key: "onboarding.card.import.sourceList" },
done: false, done: false,
next: { kind: "wait" }, next: { kind: "wait" },
@ -62,7 +66,7 @@ function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
const doneProps = (overrides: Partial<StepCardProps> = {}) => const doneProps = (overrides: Partial<StepCardProps> = {}) =>
cardProps({ cardProps({
step: row("import", { ...ZERO, sources: 1, imports: 1 }), step: row("import", { ...ZERO, sources: 1, imports: 1 }),
position: { n: 4, total: 6 }, position: { n: 4, total: 8 },
text: null, text: null,
done: true, done: true,
next: { kind: "step", step: row("manualCategory") }, next: { kind: "step", step: row("manualCategory") },
@ -92,7 +96,7 @@ describe("StepCard — the card (#398)", () => {
expect(heading).not.toBeNull(); expect(heading).not.toBeNull();
expect(heading![0]).toContain('tabindex="-1"'); expect(heading![0]).toContain('tabindex="-1"');
const title = heading![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( expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
title.indexOf("t(onboarding.checklist.items.source)"), 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)"); 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 { export interface StepCardProps {
/** The step the card shows (`cardStep`): its label is the title. */ /** The step the card shows (`cardStep`): its label is the title. */
step: ChecklistItem; 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; position: { n: number; total: number } | null;
/** The page's sentence, null when "Done." says it all. */ /** The page's sentence, null when "Done." says it all. */
text: StepCardText | null; text: StepCardText | null;
@ -23,6 +23,8 @@ export interface StepCardProps {
next: StepCardNext; next: StepCardNext;
/** "Skip this step": the provider's `skip`, for `step`. */ /** "Skip this step": the provider's `skip`, for `step`. */
onSkip: () => void; 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`. */ /** "Close" at the end of the path: the provider's `hide`. */
onHide: () => void; onHide: () => void;
/** The title, which takes the focus after an arrival by "Next step". */ /** 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"; "rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
const TEXT_BUTTON_CLASS = const TEXT_BUTTON_CLASS =
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"; "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"; 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. * "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 * - "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 * 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 * - 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 * navigation state; the end message, "Open the guide" and "Close" when no
* step is left; nothing yet while `next` waits. * step is left; nothing yet while `next` waits.
@ -60,6 +65,7 @@ export default function StepCard({
done, done,
next, next,
onSkip, onSkip,
onAcknowledge,
onHide, onHide,
titleRef, titleRef,
}: StepCardProps) { }: StepCardProps) {
@ -105,6 +111,11 @@ export default function StepCard({
</> </>
)} )}
</p> </p>
{!done && !step.done && onAcknowledge && (
<button type="button" onClick={onAcknowledge} className={ACKNOWLEDGE_BUTTON_CLASS}>
{t("onboarding.card.acknowledge")}
</button>
)}
{!done && !step.done && ( {!done && !step.done && (
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}> <button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
{t("onboarding.card.skip")} {t("onboarding.card.skip")}

View file

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

View file

@ -21,7 +21,9 @@ import {
onboardingChecklistReducer, onboardingChecklistReducer,
initialOnboardingChecklistState, initialOnboardingChecklistState,
createOnboardingLoader, createOnboardingLoader,
createVisitStepsInitializer,
deriveOnboardingView, deriveOnboardingView,
needsVisitStepsInitialization,
followDataChanges, followDataChanges,
DATA_CHANGE_DELAY_MS, DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction, type OnboardingChecklistAction,
@ -29,7 +31,7 @@ import {
type OnboardingLoaderDeps, type OnboardingLoaderDeps,
} from "./useOnboardingChecklist"; } from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents"; import { notifyDataChanged } from "../services/dataEvents";
import { computeChecklist } from "../utils/onboardingChecklist"; import { computeChecklist, shouldAcknowledgeVisitSteps } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService"; import type { OnboardingSnapshot } from "../services/onboardingService";
import type { OnboardingCounters } from "../shared/types"; import type { OnboardingCounters } from "../shared/types";
@ -38,6 +40,10 @@ const snapshot: OnboardingSnapshot = {
importFolderSet: true, importFolderSet: true,
hidden: false, hidden: false,
skipped: [], skipped: [],
// Stopped after the import: the visit steps are acknowledged and initialized
// (rule of the templates, #425).
acknowledged: ["categories", "reports"],
visitStepsInitialized: true,
}; };
const reduce = ( const reduce = (
@ -60,9 +66,12 @@ describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
importFolderSet: false, importFolderSet: false,
hidden: false, hidden: false,
skipped: [], skipped: [],
acknowledged: [],
visitStepsInitialized: false,
loadSeq: 0, loadSeq: 0,
hiddenSetAt: 0, hiddenSetAt: 0,
skippedSetAt: 0, skippedSetAt: 0,
acknowledgedSetAt: 0,
cardPage: null, cardPage: null,
}); });
}); });
@ -318,6 +327,76 @@ describe("onboardingChecklistReducer — skipped steps and the card latch (#396)
).toBe("/import"); ).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", () => { it("SET_CARD_PAGE with the same page returns the same state object", () => {
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready()); const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe( 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 // Provider view: the path end to end, without rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -492,6 +647,8 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
importFolderSet: state.importFolderSet, importFolderSet: state.importFolderSet,
budgetAllowed, budgetAllowed,
skipped: state.skipped, skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
}) })
: null; : null;
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages }); 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", () => { it("shows nothing before the first load", () => {
const { view } = render(initialOnboardingChecklistState, "/import"); 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 = { const snap = {
...fresh, ...fresh,
importFolderSet: true, importFolderSet: true,
@ -660,7 +826,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
}; };
const s = load(initialOnboardingChecklistState, snap); const s = load(initialOnboardingChecklistState, snap);
const r = render(s, "/budget", { budgetAllowed: false }); 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.view.nextStep).toBeNull();
expect(r.state.cardPage).toBeNull(); expect(r.state.cardPage).toBeNull();
expect(r.view.mode).toBe("complete"); expect(r.view.mode).toBe("complete");
@ -677,7 +843,74 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
); );
r = render(s, "/import"); r = render(s, "/import");
expect(r.view.mode).toBe("card"); 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 { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { import {
acknowledgeStep,
getOnboardingSnapshot, getOnboardingSnapshot,
hideChecklist, hideChecklist,
initializeGuidedPath, initializeGuidedPath,
initializeVisitSteps,
resumeStep, resumeStep,
showChecklist, showChecklist,
skipStep, skipStep,
@ -11,7 +13,11 @@ import {
} from "../services/onboardingService"; } from "../services/onboardingService";
import { subscribeDataChanged } from "../services/dataEvents"; import { subscribeDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService"; import { logError } from "../services/logService";
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist"; import {
computeChecklist,
shouldAcknowledgeVisitSteps,
type Checklist,
} from "../utils/onboardingChecklist";
import { import {
bannerMode, bannerMode,
latchCardPage, latchCardPage,
@ -44,12 +50,18 @@ export interface OnboardingChecklistState {
hidden: boolean; hidden: boolean;
/** Skipped steps, in checklist order. */ /** Skipped steps, in checklist order. */
skipped: ChecklistItemKey[]; 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. */ /** Number of the last load started: the outcome of any other load is stale. */
loadSeq: number; loadSeq: number;
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */ /** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
hiddenSetAt: number; hiddenSetAt: number;
/** Same, for SKIP / RESUME and the skipped steps. */ /** Same, for SKIP / RESUME and the skipped steps. */
skippedSetAt: number; skippedSetAt: number;
/** Same, for ACKNOWLEDGE and the acknowledged steps (#425). */
acknowledgedSetAt: number;
/** The page that carries a step card (`latchCardPage`), null otherwise. */ /** The page that carries a step card (`latchCardPage`), null otherwise. */
cardPage: string | null; cardPage: string | null;
} }
@ -62,6 +74,7 @@ export type OnboardingChecklistAction =
| { type: "SHOW" } | { type: "SHOW" }
| { type: "SKIP"; key: ChecklistItemKey } | { type: "SKIP"; key: ChecklistItemKey }
| { type: "RESUME"; key: ChecklistItemKey } | { type: "RESUME"; key: ChecklistItemKey }
| { type: "ACKNOWLEDGE"; key: ChecklistItemKey }
| { type: "SET_CARD_PAGE"; cardPage: string | null }; | { type: "SET_CARD_PAGE"; cardPage: string | null };
export const initialOnboardingChecklistState: OnboardingChecklistState = { export const initialOnboardingChecklistState: OnboardingChecklistState = {
@ -70,9 +83,12 @@ export const initialOnboardingChecklistState: OnboardingChecklistState = {
importFolderSet: false, importFolderSet: false,
hidden: false, hidden: false,
skipped: [], skipped: [],
acknowledged: [],
visitStepsInitialized: false,
loadSeq: 0, loadSeq: 0,
hiddenSetAt: 0, hiddenSetAt: 0,
skippedSetAt: 0, skippedSetAt: 0,
acknowledgedSetAt: 0,
cardPage: null, cardPage: null,
}; };
@ -106,6 +122,9 @@ export function onboardingChecklistReducer(
// snapshot, read before the action's write (#384). // snapshot, read before the action's write (#384).
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden, hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped, skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
acknowledged:
action.seq <= state.acknowledgedSetAt ? state.acknowledged : snapshot.acknowledged,
visitStepsInitialized: snapshot.visitStepsInitialized,
}; };
} }
case "LOAD_ERROR": case "LOAD_ERROR":
@ -132,6 +151,14 @@ export function onboardingChecklistReducer(
skipped: state.skipped.filter((key) => key !== action.key), skipped: state.skipped.filter((key) => key !== action.key),
skippedSetAt: state.loadSeq, 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": case "SET_CARD_PAGE":
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage }; 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). */ /** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
export const DATA_CHANGE_DELAY_MS = 150; export const DATA_CHANGE_DELAY_MS = 150;
@ -255,6 +329,9 @@ export function deriveOnboardingView({
budgetReady, budgetReady,
cardPages, cardPages,
}: OnboardingViewInput): OnboardingView { }: 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 nextStep = checklist ? findNextStep(checklist) : null;
const cardPage = latchCardPage(state.cardPage, { const cardPage = latchCardPage(state.cardPage, {
pathname, pathname,
@ -268,7 +345,7 @@ export function deriveOnboardingView({
hidden: state.hidden, hidden: state.hidden,
pathname, pathname,
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0, isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
budgetReady, budgetReady: totalKnown,
cardPage, cardPage,
}); });
return { nextStep, cardPage, mode }; return { nextStep, cardPage, mode };
@ -282,6 +359,11 @@ export interface OnboardingValue {
counters: OnboardingCounters | null; counters: OnboardingCounters | null;
hidden: boolean; hidden: boolean;
budgetReady: 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`. */ /** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
nextStep: ChecklistItem | null; nextStep: ChecklistItem | null;
/** What the current page shows of the path (`bannerMode`). */ /** What the current page shows of the path (`bannerMode`). */
@ -292,18 +374,22 @@ export interface OnboardingValue {
show: () => Promise<void>; show: () => Promise<void>;
skip: (key: ChecklistItemKey) => Promise<void>; skip: (key: ChecklistItemKey) => Promise<void>;
resume: (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>; refresh: () => Promise<void>;
} }
/** /**
* Getting-started path: counters read from the profile database, hidden and * 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 * row is locked, and what each page shows of it. Called once, by
* `OnboardingProvider`; pages read it through `useOnboarding()`. * `OnboardingProvider`; pages read it through `useOnboarding()`.
* *
* Re-read at mount, at every route change and 150 ms after a burst of data * 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 * 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 { export function useOnboardingChecklist(): OnboardingValue {
const [state, dispatch] = useReducer( const [state, dispatch] = useReducer(
@ -319,6 +405,11 @@ export function useOnboardingChecklist(): OnboardingValue {
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }), 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. // Read at mount, then again at every route change.
useEffect(() => { useEffect(() => {
void refresh(); 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( const checklist: Checklist | null = useMemo(
() => () =>
state.counters state.counters
@ -370,11 +470,28 @@ export function useOnboardingChecklist(): OnboardingValue {
importFolderSet: state.importFolderSet, importFolderSet: state.importFolderSet,
budgetAllowed, budgetAllowed,
skipped: state.skipped, skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
}) })
: null, : 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({ const { nextStep, cardPage, mode } = deriveOnboardingView({
state, state,
checklist, checklist,
@ -396,6 +513,7 @@ export function useOnboardingChecklist(): OnboardingValue {
counters: state.counters, counters: state.counters,
hidden: state.hidden, hidden: state.hidden,
budgetReady, budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep, nextStep,
mode, mode,
cardPage, cardPage,
@ -403,6 +521,7 @@ export function useOnboardingChecklist(): OnboardingValue {
show, show,
skip, skip,
resume, resume,
acknowledge,
refresh, refresh,
}), }),
[ [
@ -411,6 +530,7 @@ export function useOnboardingChecklist(): OnboardingValue {
state.counters, state.counters,
state.hidden, state.hidden,
budgetReady, budgetReady,
state.visitStepsInitialized,
nextStep, nextStep,
mode, mode,
cardPage, cardPage,
@ -418,6 +538,7 @@ export function useOnboardingChecklist(): OnboardingValue {
show, show,
skip, skip,
resume, resume,
acknowledge,
refresh, refresh,
], ],
); );

View file

@ -11,9 +11,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig())); 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("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/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../services/onboardingService", () => ({ vi.mock("../services/onboardingService", () => ({
getOnboardingSnapshot: vi.fn(), getOnboardingSnapshot: vi.fn(),
@ -22,6 +24,8 @@ vi.mock("../services/onboardingService", () => ({
showChecklist: vi.fn(), showChecklist: vi.fn(),
skipStep: vi.fn(), skipStep: vi.fn(),
resumeStep: vi.fn(), resumeStep: vi.fn(),
acknowledgeStep: vi.fn(),
initializeVisitSteps: vi.fn(),
})); }));
import { hookHarness } from "../test/hookHarness"; import { hookHarness } from "../test/hookHarness";
@ -34,9 +38,11 @@ import {
import { notifyDataChanged } from "../services/dataEvents"; import { notifyDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService"; import { logError } from "../services/logService";
import { import {
acknowledgeStep,
getOnboardingSnapshot, getOnboardingSnapshot,
hideChecklist, hideChecklist,
initializeGuidedPath, initializeGuidedPath,
initializeVisitSteps,
resumeStep, resumeStep,
showChecklist, showChecklist,
skipStep, skipStep,
@ -50,6 +56,10 @@ const snapshot: OnboardingSnapshot = {
importFolderSet: true, importFolderSet: true,
hidden: false, hidden: false,
skipped: [], 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 = [ const ready: OnboardingChecklistState = [
@ -76,9 +86,17 @@ const actionsOfType = (type: string) =>
beforeEach(() => { beforeEach(() => {
harness.clear(); harness.clear();
ctx.pathname = "/"; ctx.pathname = "/";
ctx.budgetReady = true;
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot); vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false }); 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(write).mockReset().mockResolvedValue(undefined);
} }
vi.mocked(logError).mockReset(); vi.mocked(logError).mockReset();
@ -192,7 +210,7 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(value.nextStep?.key).toBe("manualCategory"); expect(value.nextStep?.key).toBe("manualCategory");
expect(value.mode).toBe("banner"); expect(value.mode).toBe("banner");
expect(value.cardPage).toBeNull(); 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 () => { 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); 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 () => { it("an action whose write fails leaves the state alone and logs", async () => {
mountWith(ready); mountWith(ready);
const value = render(); const value = render();
@ -283,3 +327,85 @@ describe("useOnboardingChecklist — what survives a render (#409)", () => {
expect(reports.mode).toBe("banner"); 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": { "card": {
"stepOf": "Step {{n}} of {{total}}", "stepOf": "Step {{n}} of {{total}}",
"skip": "Skip this step", "skip": "Skip this step",
"acknowledge": "Got it",
"done": "Done.", "done": "Done.",
"next": "Next step: {{step}} →", "next": "Next step: {{step}} →",
"import": { "import": {
@ -117,8 +118,10 @@
"importFolder": "Choose an import folder", "importFolder": "Choose an import folder",
"source": "Set up an import source", "source": "Set up an import source",
"import": "Import a first file", "import": "Import a first file",
"categories": "Browse categories and keywords",
"manualCategory": "Categorize a transaction by hand", "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": { "card": {
"stepOf": "Étape {{n}} sur {{total}}", "stepOf": "Étape {{n}} sur {{total}}",
"skip": "Passer cette étape", "skip": "Passer cette étape",
"acknowledge": "J'ai compris",
"done": "Fait.", "done": "Fait.",
"next": "Étape suivante : {{step}} →", "next": "Étape suivante : {{step}} →",
"import": { "import": {
@ -117,8 +118,10 @@
"importFolder": "Choisir un dossier d'import", "importFolder": "Choisir un dossier d'import",
"source": "Configurer une source d'import", "source": "Configurer une source d'import",
"import": "Importer un premier fichier", "import": "Importer un premier fichier",
"categories": "Parcourir les catégories et les mots-clés",
"manualCategory": "Classer une transaction à la main", "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. */ /** The path latched on /budget (card mode), as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue { 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 { return {
status: "ready", status: "ready",
checklist, checklist,
counters, counters,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
mode: "card", mode: "card",
cardPage: "/budget", cardPage: "/budget",
@ -92,6 +99,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...overrides, ...overrides,
}; };
@ -132,7 +140,7 @@ describe("BudgetPage — the step card (#400)", () => {
const html = render(); const html = render();
expect(count(html, CARD)).toBe(1); expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1")); 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.checklist.items.budget)");
expect(html).toContain("t(onboarding.card.skip)"); expect(html).toContain("t(onboarding.card.skip)");
// The grid is there, below the card. // 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. */ /** The path latched on /budget (card mode), as the provider shares it. */
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue { 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 { return {
status: "ready", status: "ready",
checklist, checklist,
counters, counters,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
mode: "card", mode: "card",
cardPage: "/budget", cardPage: "/budget",
@ -77,6 +84,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...overrides, ...overrides,
}; };
@ -172,6 +180,8 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
importFolderSet: true, importFolderSet: true,
budgetAllowed: true, budgetAllowed: true,
skipped: ["source", "import", "manualCategory"], skipped: ["source", "import", "manualCategory"],
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
}); });
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) }); ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = ctx.budget as { state: BudgetState }; const budget = ctx.budget as { state: BudgetState };

View file

@ -82,10 +82,16 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
const c = counters(transactions); const c = counters(transactions);
return { return {
status: "ready", 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, counters: c,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: null, nextStep: null,
mode: "banner", mode: "banner",
cardPage: null, cardPage: null,
@ -93,6 +99,7 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...rest, ...rest,
}; };

View file

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

View file

@ -82,13 +82,20 @@ function path(
importFolderSet: boolean, importFolderSet: boolean,
overrides: Partial<OnboardingValue> = {}, overrides: Partial<OnboardingValue> = {},
): 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 { return {
status: "ready", status: "ready",
checklist, checklist,
counters, counters,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
mode: "card", mode: "card",
cardPage: "/import", cardPage: "/import",
@ -96,6 +103,7 @@ function path(
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...overrides, ...overrides,
}; };
@ -136,7 +144,7 @@ describe("ImportPage — the step card (#398)", () => {
const html = render(); const html = render();
expect(count(html, CARD)).toBe(1); expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1")); 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", () => { 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.wizard = wizard({ importFolder: "/home/max/Relevés" });
harness.onboarding = path(ZERO, true); harness.onboarding = path(ZERO, true);
const html = render(); 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( expect(sentence(html)).toBe(
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])", "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(); const html = render();
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/); expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/); 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); 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.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path({ ...ZERO, sources: 1 }, true); harness.onboarding = path({ ...ZERO, sources: 1 }, true);
const html = render(); 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(html).not.toContain("t(onboarding.card.done)");
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])"); expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html); const target = highlighted(html);

View file

@ -59,13 +59,20 @@ function path(
overrides: Partial<OnboardingValue> = {}, overrides: Partial<OnboardingValue> = {},
budgetAllowed = true, budgetAllowed = true,
): OnboardingValue { ): 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 { return {
status: "ready", status: "ready",
checklist, checklist,
counters, counters,
hidden: false, hidden: false,
budgetReady: true, budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist), nextStep: nextStep(checklist),
mode: "card", mode: "card",
cardPage: "/transactions", cardPage: "/transactions",
@ -73,6 +80,7 @@ function path(
show: vi.fn(async () => undefined), show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined), skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined), resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
...overrides, ...overrides,
}; };
@ -152,7 +160,7 @@ describe("TransactionsPage — the step card (#399)", () => {
const html = render(GUIDED); const html = render(GUIDED);
expect(count(html, CARD)).toBe(1); expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1")); 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)"); 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", () => { it("in the Free edition, the step checked ends the path", () => {
harness.onboarding = path(CATEGORIZED, {}, false); harness.onboarding = path(CATEGORIZED, {}, false);
const html = render(); 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.card.done)");
expect(html).toContain("t(onboarding.banner.complete)"); expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain('href="/budget"'); expect(html).not.toContain('href="/budget"');

View file

@ -20,9 +20,13 @@ import { subscribeDataChanged } from "./dataEvents";
import { import {
ONBOARDING_CHECKLIST_HIDDEN_KEY, ONBOARDING_CHECKLIST_HIDDEN_KEY,
ONBOARDING_GUIDED_INITIALIZED_KEY, ONBOARDING_GUIDED_INITIALIZED_KEY,
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
acknowledgeStep,
acknowledgedStepKey,
getOnboardingSnapshot, getOnboardingSnapshot,
hideChecklist, hideChecklist,
initializeGuidedPath, initializeGuidedPath,
initializeVisitSteps,
resumeStep, resumeStep,
showChecklist, showChecklist,
skipStep, skipStep,
@ -66,6 +70,11 @@ function makeFakeDb(options: FakeDbOptions = {}) {
.filter(([key]) => /^onboarding.skipped./.test(key)) .filter(([key]) => /^onboarding.skipped./.test(key))
.map(([key, value]) => ({ key, value })); .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")) { if (sql.includes("FROM user_preferences WHERE key = $1")) {
const key = String(params?.[0]); const key = String(params?.[0]);
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : []; 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(); const { db } = useFakeDb();
await getOnboardingSnapshot(); await getOnboardingSnapshot();
expect(db.select).toHaveBeenCalledTimes(4); expect(db.select).toHaveBeenCalledTimes(6);
const sqls = db.select.mock.calls.map((c) => String(c[0])); const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!; const aggregate = sqls.find((s) => s.includes("import_sources"))!;
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1"); 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")) .filter((c) => String(c[0]).includes("WHERE key = $1"))
.map((c) => (c[1] as unknown[])[0]); .map((c) => (c[1] as unknown[])[0]);
expect(prefKeys).toEqual( 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_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. // 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 () => { it("reads the skipped steps in checklist order", async () => {
useFakeDb({ useFakeDb({
prefs: { prefs: {
@ -218,7 +266,7 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]); 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({ useFakeDb({
prefs: { prefs: {
onboarding_skipped_dragons: "1", onboarding_skipped_dragons: "1",
@ -468,3 +516,97 @@ describe("skipStep / resumeStep (#396, D3)", () => {
expect(listener).not.toHaveBeenCalled(); 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 { logInfo } from "./logService";
import { import {
CHECKLIST_ITEM_KEYS, CHECKLIST_ITEM_KEYS,
VISIT_STEP_KEYS,
type ChecklistItemKey, type ChecklistItemKey,
type OnboardingCounters, type OnboardingCounters,
} from "../shared/types"; } from "../shared/types";
@ -17,9 +18,11 @@ import {
* *
* `getOnboardingSnapshot` reads everything the path needs in one round of * `getOnboardingSnapshot` reads everything the path needs in one round of
* parallel reads, and writes nothing: the counters, the import folder, the * parallel reads, and writes nothing: the counters, the import folder, the
* "hidden" preference and the skipped steps. The writes are separate, and the * "hidden" preference, the skipped and acknowledged steps, and the visit-steps
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9), * flag. The writes are separate, and the onboarding provider is their only
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is * 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 * announced on the data channel: the provider updates its own state. No UI
* lives here; `computeChecklist` turns the snapshot into rows and * lives here; `computeChecklist` turns the snapshot into rows and
* `useOnboardingChecklist` holds the state. * `useOnboardingChecklist` holds the state.
@ -36,14 +39,26 @@ export function skippedStepKey(key: ChecklistItemKey): string {
return `onboarding_skipped_${key}`; 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 { export interface OnboardingSnapshot {
counters: OnboardingCounters; counters: OnboardingCounters;
/** True when an import folder has been chosen. */ /** True when an import folder has been chosen. */
importFolderSet: boolean; importFolderSet: boolean;
/** True when the path is hidden (by the user, or by rule D9). */ /** True when the path is hidden (by the user, or by rule D9). */
hidden: boolean; 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[]; 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 { interface CountersRow {
@ -74,6 +89,10 @@ const COUNTERS_SQL = `
const SKIPPED_SQL = const SKIPPED_SQL =
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'"; "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) const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP) VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = 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))); 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 * Reads the counters, the import folder, the hidden flag, the skipped and
* in parallel. A pure read: it never writes (rule D9 lives in * acknowledged steps and the visit-steps flag in parallel. A pure read: it
* `initializeGuidedPath`). * never writes (rule D9 lives in `initializeGuidedPath`, the visit steps'
* initialization in `initializeVisitSteps`).
*/ */
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> { export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([ const [counters, importFolder, hiddenPref, skipped, acknowledged, visitFlag] =
readCounters(), await Promise.all([
getImportFolder(), readCounters(),
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY), getImportFolder(),
readSkippedSteps(), getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
]); readSkippedSteps(),
readAcknowledgedSteps(),
getPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY),
]);
return { return {
counters, counters,
importFolderSet: importFolder !== null && importFolder !== "", importFolderSet: importFolder !== null && importFolder !== "",
hidden: hiddenPref === "1", hidden: hiddenPref === "1",
skipped, skipped,
acknowledged,
visitStepsInitialized: visitFlag === "1",
}; };
} }
@ -201,3 +234,34 @@ export async function resumeStep(key: ChecklistItemKey): Promise<void> {
await deletePreference(skippedStepKey(key)); await deletePreference(skippedStepKey(key));
logInfo(`Onboarding: step ${key} resumed`); 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" | "importFolder"
| "source" | "source"
| "import" | "import"
| "categories"
| "manualCategory" | "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[] = [ export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
"profile", "profile",
"importFolder", "importFolder",
"source", "source",
"import", "import",
"categories",
"manualCategory", "manualCategory",
"budget", "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. */ /** One row of the getting-started checklist. `to` is the route the row links to. */
export interface ChecklistItem { export interface ChecklistItem {
key: ChecklistItemKey; key: ChecklistItemKey;
@ -1002,7 +1013,10 @@ export interface ChecklistItem {
done: boolean; done: boolean;
/** The user skipped the step (#396, D3). A row can be done and skipped. */ /** The user skipped the step (#396, D3). A row can be done and skipped. */
skipped: boolean; 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; locked: boolean;
to: string; to: string;
} }

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeChecklist } from "./onboardingChecklist"; import { computeChecklist, shouldAcknowledgeVisitSteps } from "./onboardingChecklist";
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types"; import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
const zero: OnboardingCounters = { const zero: OnboardingCounters = {
@ -10,7 +10,9 @@ const zero: OnboardingCounters = {
transactions: 0, 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[] { function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
return computeChecklist(counters, opts).items.map((i) => i.key); return computeChecklist(counters, opts).items.map((i) => i.key);
@ -23,22 +25,24 @@ function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[]
} }
describe("computeChecklist — rows (#351)", () => { describe("computeChecklist — rows (#351)", () => {
it("lists the six rows in order", () => { it("lists the eight rows in order (#425)", () => {
expect(keys(zero)).toEqual([ expect(keys(zero)).toEqual([
"profile", "profile",
"importFolder", "importFolder",
"source", "source",
"import", "import",
"categories",
"manualCategory", "manualCategory",
"budget", "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); const list = computeChecklist(zero, open);
expect(doneKeys(zero)).toEqual(["profile"]); expect(doneKeys(zero)).toEqual(["profile"]);
expect(list.done).toBe(1); expect(list.done).toBe(1);
expect(list.total).toBe(6); expect(list.total).toBe(8);
expect(list.complete).toBe(false); expect(list.complete).toBe(false);
}); });
@ -79,8 +83,10 @@ describe("computeChecklist — rows (#351)", () => {
importFolder: "/import", importFolder: "/import",
source: "/import", source: "/import",
import: "/import", import: "/import",
categories: "/categories",
manualCategory: "/transactions", manualCategory: "/transactions",
budget: "/budget", budget: "/budget",
reports: "/reports",
}); });
}); });
@ -90,11 +96,11 @@ describe("computeChecklist — rows (#351)", () => {
}); });
describe("computeChecklist — budget gating (#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); const list = computeChecklist(zero, locked);
expect(list.total).toBe(5); expect(list.total).toBe(7);
const budget = list.items.find((i) => i.key === "budget")!; const budget = list.items.find((i) => i.key === "budget")!;
expect(budget.locked).toBe(true); expect(budget.locked).toBe(true);
expect(budget.to).toBe("/settings/users"); expect(budget.to).toBe("/settings/users");
@ -106,27 +112,27 @@ describe("computeChecklist — budget gating (#351)", () => {
expect(list.done).toBe(1); 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 all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
const list = computeChecklist(all, { ...locked, importFolderSet: true }); const list = computeChecklist(all, { ...locked, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(5); expect(list.done).toBe(7);
expect(list.total).toBe(5); expect(list.total).toBe(7);
expect(list.complete).toBe(true); 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 all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
const list = computeChecklist(all, { ...open, importFolderSet: true }); const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(5); expect(list.done).toBe(7);
expect(list.total).toBe(6); expect(list.total).toBe(8);
expect(list.complete).toBe(false); 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 all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
const list = computeChecklist(all, { ...open, importFolderSet: true }); const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
expect(list.done).toBe(6); expect(list.done).toBe(8);
expect(list.total).toBe(6); expect(list.total).toBe(8);
expect(list.complete).toBe(true); expect(list.complete).toBe(true);
}); });
}); });
@ -147,21 +153,31 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
expect(row.skipped).toBe(true); expect(row.skipped).toBe(true);
expect(row.done).toBe(false); expect(row.done).toBe(false);
expect(list.done).toBe(2); // profile + the skipped row 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", () => { it("complete with done and skipped rows", () => {
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] }); const list = computeChecklist(fiveDone, {
expect(list.done).toBe(6); ...open,
expect(list.total).toBe(6); importFolderSet: true,
skipped: ["budget"],
acknowledged: visited,
});
expect(list.done).toBe(8);
expect(list.total).toBe(8);
expect(list.complete).toBe(true); expect(list.complete).toBe(true);
}); });
it("a locked row stays out of the total, even skipped", () => { 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")!; const budget = list.items.find((i) => i.key === "budget")!;
expect(budget.locked).toBe(true); expect(budget.locked).toBe(true);
expect(list.total).toBe(5); expect(list.total).toBe(7);
expect(list.done).toBe(1); expect(list.done).toBe(1);
expect(list.complete).toBe(false); expect(list.complete).toBe(false);
}); });
@ -171,12 +187,13 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
...open, ...open,
importFolderSet: true, importFolderSet: true,
skipped: ["source", "import", "budget"], skipped: ["source", "import", "budget"],
acknowledged: visited,
}); });
const source = list.items.find((i) => i.key === "source")!; const source = list.items.find((i) => i.key === "source")!;
expect(source.done).toBe(true); expect(source.done).toBe(true);
expect(source.skipped).toBe(true); expect(source.skipped).toBe(true);
expect(list.done).toBe(6); expect(list.done).toBe(8);
expect(list.total).toBe(6); expect(list.total).toBe(8);
}); });
it("skipping does not change the rows' links", () => { 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)); 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 * 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 * edition does not include Budget; a locked row is excluded from `total` and
* from `complete`, and links to the Users settings (license card) instead. * 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 * 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 * done one, once even when it is both, unless it is locked: a locked row stays
* out of the total whatever its state. * out of the total whatever its state.
@ -20,13 +31,17 @@ export interface ChecklistOptions {
budgetAllowed: boolean; budgetAllowed: boolean;
/** Steps the user skipped (#396). None when omitted. */ /** Steps the user skipped (#396). None when omitted. */
skipped?: readonly ChecklistItemKey[]; 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 { export interface Checklist {
items: ChecklistItem[]; items: ChecklistItem[];
/** Number of unlocked rows that are done or skipped. */ /** Number of unlocked rows that are done or skipped. */
done: number; 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; total: number;
/** True when every unlocked row is done or skipped. */ /** True when every unlocked row is done or skipped. */
complete: boolean; complete: boolean;
@ -37,7 +52,9 @@ export function computeChecklist(
options: ChecklistOptions options: ChecklistOptions
): Checklist { ): Checklist {
const budgetLocked = !options.budgetAllowed; const budgetLocked = !options.budgetAllowed;
const visitLocked = !options.visitStepsInitialized;
const skipped = new Set(options.skipped ?? []); const skipped = new Set(options.skipped ?? []);
const acknowledged = new Set(options.acknowledged ?? []);
const row = ( const row = (
key: ChecklistItemKey, key: ChecklistItemKey,
done: boolean, done: boolean,
@ -52,6 +69,7 @@ export function computeChecklist(
row("importFolder", options.importFolderSet, "/import"), row("importFolder", options.importFolderSet, "/import"),
row("source", counters.sources >= 1, "/import"), row("source", counters.sources >= 1, "/import"),
row("import", counters.imports >= 1, "/import"), row("import", counters.imports >= 1, "/import"),
row("categories", acknowledged.has("categories"), "/categories", visitLocked),
row("manualCategory", counters.manualCategorized >= 1, "/transactions"), row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
row( row(
"budget", "budget",
@ -59,6 +77,7 @@ export function computeChecklist(
budgetLocked ? "/settings/users" : "/budget", budgetLocked ? "/settings/users" : "/budget",
budgetLocked, budgetLocked,
), ),
row("reports", acknowledged.has("reports"), "/reports", visitLocked),
]; ];
const unlocked = items.filter((item) => !item.locked); const unlocked = items.filter((item) => !item.locked);
@ -67,3 +86,15 @@ export function computeChecklist(
return { items, done, total, complete: done === total }; 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, 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 = ( const checklistOf = (
counters: OnboardingCounters, counters: OnboardingCounters,
options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {}, options: {
importFolderSet?: boolean;
budgetAllowed?: boolean;
skipped?: ChecklistItemKey[];
acknowledged?: ChecklistItemKey[];
visitStepsInitialized?: boolean;
} = {},
): Checklist => ): Checklist =>
computeChecklist(counters, { computeChecklist(counters, {
importFolderSet: options.importFolderSet ?? false, importFolderSet: options.importFolderSet ?? false,
budgetAllowed: options.budgetAllowed ?? true, budgetAllowed: options.budgetAllowed ?? true,
skipped: options.skipped, skipped: options.skipped,
acknowledged: options.acknowledged ?? ["categories", "reports"],
visitStepsInitialized: options.visitStepsInitialized ?? true,
}); });
/** A row of the given step, as `computeChecklist` builds it. */ /** 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 }))) expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
.toMatchObject({ key: "manualCategory", to: "/transactions" }); .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)", () => { describe("stepsOfPage (#396)", () => {
@ -286,7 +315,7 @@ describe("bannerMode (#396)", () => {
}); });
it("waits for the Budget entitlement before announcing the end", () => { 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 }); const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
expect(failClosed.complete).toBe(true); expect(failClosed.complete).toBe(true);
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none"); 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", () => { it("shows the banner before the Budget entitlement is known when steps remain", () => {
expect(mode({ budgetReady: false })).toBe("banner"); 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)", () => { 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", () => { 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))).toEqual({ key: "importFolder", n: 2, total: 8 });
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 }); expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 8 });
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({ expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
key: "import", key: "import",
n: 4, n: 4,
total: 6, total: 8,
}); });
}); });
@ -404,7 +444,7 @@ describe("cardStep (#398)", () => {
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({ expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
key: "import", key: "import",
n: 4, 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({ expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
key: "importFolder", key: "importFolder",
n: 2, n: 2,
total: 5, total: 7,
}); });
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({ expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
key: "manualCategory", key: "manualCategory",
n: 5, n: 6,
total: 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", () => { it("is null on a page that guides no unlocked step", () => {

View file

@ -88,7 +88,7 @@ export interface BannerModeInput {
pathname: string; pathname: string;
/** The dashboard of a profile without any transaction (D11). */ /** The dashboard of a profile without any transaction (D11). */
isEmptyDashboard: boolean; 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; budgetReady: boolean;
/** The latched card page (`latchCardPage`). */ /** The latched card page (`latchCardPage`). */
cardPage: string | null; cardPage: string | null;
@ -102,7 +102,9 @@ export interface BannerModeInput {
* then says so itself); * then says so itself);
* - `complete` when every unlocked step is done or skipped (D4), and only once * - `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'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. * - `banner` everywhere else. Its counter waits for `budgetReady` too.
* *
* A failed re-read after a first success keeps status `ready` (reducer), so * 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; * - `step`: the next step, which lives on another page;
* - `end`: no step left (end mode), once the Budget entitlement is known: * - `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 * 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 * - `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 * 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 * follows the write that completed them (ADR 0022) has not landed, and a