diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index 3143f43..7d3b5f0 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -1,12 +1,16 @@
import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";
+import { OnboardingProvider } from "../../contexts/OnboardingContext";
export default function AppShell() {
return (
-
+ {/* The getting-started path, shared by every page (#396). */}
+
+
+
);
diff --git a/src/contexts/OnboardingContext.render.test.tsx b/src/contexts/OnboardingContext.render.test.tsx
new file mode 100644
index 0000000..bd71b9a
--- /dev/null
+++ b/src/contexts/OnboardingContext.render.test.tsx
@@ -0,0 +1,71 @@
+// OnboardingProvider wiring (#396). No jsdom: react-dom/server renders in
+// vitest's node environment. Effects do not run under renderToStaticMarkup, so
+// what is pinned here is the mounting, not the loads (the loader, the reducer
+// and the derivation are tested in useOnboardingChecklist.test.ts).
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+
+vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
+vi.mock("./LicenseContext", () => ({
+ useLicenseContext: () => ({ status: "ready", edition: "base", features: [] }),
+}));
+vi.mock("../components/layout/Sidebar", () => ({ default: () => }));
+
+import AppShell from "../components/layout/AppShell";
+import { OnboardingProvider, useOnboarding } from "./OnboardingContext";
+
+function Probe() {
+ const onboarding = useOnboarding();
+ return (
+
+ );
+}
+
+describe("OnboardingProvider (#396)", () => {
+ it("useOnboarding throws outside the provider", () => {
+ expect(() =>
+ renderToStaticMarkup(
+
+
+ ,
+ ),
+ ).toThrow("useOnboarding must be used within OnboardingProvider");
+ });
+
+ it("shares the path, still loading on the first render", () => {
+ const html = renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+ expect(html).toContain("loading|none|null|null|null|function|function");
+ });
+
+ it("AppShell mounts the provider around the routed page", () => {
+ const html = renderToStaticMarkup(
+
+
+ }>
+ } />
+
+
+ ,
+ );
+ expect(html).toContain("sidebar");
+ expect(html).toContain("(null);
+
+export function OnboardingProvider({ children }: { children: ReactNode }) {
+ const value = useOnboardingChecklist();
+ return {children};
+}
+
+/** The shared getting-started path. Throws outside `OnboardingProvider`. */
+export function useOnboarding(): OnboardingValue {
+ const ctx = useContext(OnboardingContext);
+ if (!ctx) throw new Error("useOnboarding must be used within OnboardingProvider");
+ return ctx;
+}
diff --git a/src/hooks/useOnboardingChecklist.test.ts b/src/hooks/useOnboardingChecklist.test.ts
index db5fd23..45f2e12 100644
--- a/src/hooks/useOnboardingChecklist.test.ts
+++ b/src/hooks/useOnboardingChecklist.test.ts
@@ -1,9 +1,9 @@
-// useOnboardingChecklist — reducer unit tests (#351).
+// useOnboardingChecklist — reducer, loader and data-channel follower (#351, #396).
//
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
-// the exported reducer is driven directly.
+// the exported reducer, loader and follower are driven directly.
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// The hook module imports the onboarding service (→ db → tauri plugins) and
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
@@ -20,15 +20,24 @@ vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
import {
onboardingChecklistReducer,
initialOnboardingChecklistState,
+ createOnboardingLoader,
+ deriveOnboardingView,
+ followDataChanges,
+ DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction,
type OnboardingChecklistState,
+ type OnboardingLoaderDeps,
} from "./useOnboardingChecklist";
+import { notifyDataChanged } from "../services/dataEvents";
+import { computeChecklist } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService";
+import type { OnboardingCounters } from "../shared/types";
const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true,
hidden: false,
+ skipped: [],
};
const reduce = (
@@ -36,87 +45,688 @@ const reduce = (
from: OnboardingChecklistState = initialOnboardingChecklistState,
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
-describe("onboardingChecklistReducer (#351)", () => {
- it("starts loading with no counters and not hidden", () => {
+/** A first successful load (seq 1). */
+const ready = (snap: OnboardingSnapshot = snapshot): OnboardingChecklistState =>
+ reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_DONE", seq: 1, snapshot: snap },
+ ]);
+
+describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
+ it("starts loading with no counters, nothing hidden or skipped, and no card", () => {
expect(initialOnboardingChecklistState).toEqual({
status: "loading",
counters: null,
importFolderSet: false,
hidden: false,
- error: null,
+ skipped: [],
+ loadSeq: 0,
+ hiddenSetAt: 0,
+ skippedSetAt: 0,
+ cardPage: null,
});
});
it("LOAD_DONE stores the snapshot and sets ready", () => {
- const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_DONE", snapshot }]);
+ const s = ready({ ...snapshot, skipped: ["budget"] });
expect(s.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters);
expect(s.importFolderSet).toBe(true);
expect(s.hidden).toBe(false);
- expect(s.error).toBeNull();
+ expect(s.skipped).toEqual(["budget"]);
});
it("LOAD_DONE applies the persisted hidden flag", () => {
- const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
- expect(s.hidden).toBe(true);
+ expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
});
- it("LOAD_ERROR sets status=error with the message and keeps hidden", () => {
- const hiddenReady = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
+ it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
+ const s = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: true } },
+ ]);
+ expect(s.status).toBe("loading");
+ expect(s.counters).toBeNull();
+ expect(s.hidden).toBe(false);
+
+ const answered = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], s);
+ expect(answered.status).toBe("ready");
+ expect(answered.counters).toEqual(snapshot.counters);
+ });
+
+ it("a stale LOAD_DONE arriving after the newer one does not overwrite it", () => {
+ const newer = { ...snapshot, counters: { ...snapshot.counters, imports: 4 } };
+ const s = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_DONE", seq: 2, snapshot: newer },
+ { type: "LOAD_DONE", seq: 1, snapshot },
+ ]);
+ expect(s.counters?.imports).toBe(4);
+ });
+
+ it("a refresh keeps ready and the counters while it runs", () => {
+ const s = reduce([{ type: "LOAD_START", seq: 2 }], ready());
+ expect(s.status).toBe("ready");
+ expect(s.counters).toEqual(snapshot.counters);
+ expect(s.importFolderSet).toBe(true);
+ expect(s.loadSeq).toBe(2);
+ });
+
+ it("a failed re-read after a first success keeps ready and the last state", () => {
+ const hiddenReady = ready({ ...snapshot, hidden: true, skipped: ["source"] });
const s = reduce(
- [{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }],
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_ERROR", seq: 2 },
+ ],
hiddenReady,
);
- expect(s.status).toBe("error");
- expect(s.error).toBe("database is locked");
- expect(s.hidden).toBe(true);
- // The last known counters survive a failed refresh.
+ expect(s.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters);
+ expect(s.hidden).toBe(true);
+ expect(s.skipped).toEqual(["source"]);
});
- it("LOAD_ERROR on a fresh state keeps hidden=false", () => {
- const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
+ it("a first load that fails sets status=error and keeps hidden=false", () => {
+ const s = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_ERROR", seq: 1 },
+ ]);
expect(s.status).toBe("error");
expect(s.hidden).toBe(false);
expect(s.counters).toBeNull();
});
- it("refresh after an error goes back to loading, then ready", () => {
- const errored = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
- const loading = reduce([{ type: "LOAD_START" }], errored);
- expect(loading.status).toBe("loading");
- expect(loading.error).toBeNull();
- const ready = reduce([{ type: "LOAD_DONE", snapshot }], loading);
- expect(ready.status).toBe("ready");
- expect(ready.error).toBeNull();
- expect(ready.counters).toEqual(snapshot.counters);
- });
-
- it("HIDE sets hidden=true without touching the load state", () => {
- const ready = reduce([{ type: "LOAD_DONE", snapshot }]);
- const s = reduce([{ type: "HIDE" }], ready);
- expect(s.hidden).toBe(true);
- expect(s.status).toBe("ready");
- expect(s.counters).toEqual(snapshot.counters);
- });
-
- it("SHOW sets hidden=false without touching the load state", () => {
- const hidden = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
- const s = reduce([{ type: "SHOW" }], hidden);
- expect(s.hidden).toBe(false);
- expect(s.status).toBe("ready");
- });
-
- it("HIDE while loading does not change the status", () => {
- const s = reduce([{ type: "LOAD_START" }, { type: "HIDE" }]);
+ it("a stale LOAD_ERROR is ignored", () => {
+ const s = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_ERROR", seq: 1 },
+ ]);
expect(s.status).toBe("loading");
- expect(s.hidden).toBe(true);
+ });
+
+ it("a retry after a failed first load goes back to loading, then ready", () => {
+ const errored = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_ERROR", seq: 1 },
+ ]);
+ const loading = reduce([{ type: "LOAD_START", seq: 2 }], errored);
+ expect(loading.status).toBe("loading");
+ const done = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], loading);
+ expect(done.status).toBe("ready");
+ expect(done.counters).toEqual(snapshot.counters);
});
it("never mutates the previous state", () => {
- const before = reduce([{ type: "LOAD_DONE", snapshot }]);
+ const before = ready();
const frozen = Object.freeze({ ...before });
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
+ expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
expect(frozen.hidden).toBe(false);
+ expect(frozen.skipped).toEqual([]);
+ });
+});
+
+describe("onboardingChecklistReducer — local actions win over an older snapshot (#384, #396)", () => {
+ it("HIDE during a load wins over that load's snapshot", () => {
+ const s = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "HIDE" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
+ ],
+ ready(),
+ );
+ expect(s.hidden).toBe(true);
+ // The rest of the snapshot still lands.
+ expect(s.status).toBe("ready");
+ });
+
+ it("SHOW during a load wins over that load's snapshot", () => {
+ const s = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "SHOW" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
+ ],
+ ready({ ...snapshot, hidden: true }),
+ );
+ expect(s.hidden).toBe(false);
+ });
+
+ it("SKIP during a load wins over that load's snapshot", () => {
+ const s = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "SKIP", key: "budget" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: [] } },
+ ],
+ ready(),
+ );
+ expect(s.skipped).toEqual(["budget"]);
+ });
+
+ it("RESUME during a load wins over that load's snapshot", () => {
+ const s = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "RESUME", key: "budget" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["budget"] } },
+ ],
+ ready({ ...snapshot, skipped: ["budget"] }),
+ );
+ expect(s.skipped).toEqual([]);
+ });
+
+ it("HIDE during the very first load wins too", () => {
+ const s = reduce([
+ { type: "LOAD_START", seq: 1 },
+ { type: "HIDE" },
+ { type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: false } },
+ ]);
+ expect(s.status).toBe("ready");
+ expect(s.hidden).toBe(true);
+ });
+
+ it("a load started after the action brings the stored value back", () => {
+ // The user hid the path, then showed it again from Settings → Users:
+ // a later load reads the preference, which is the truth again.
+ const s = reduce(
+ [
+ { type: "HIDE" },
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
+ ],
+ ready(),
+ );
+ expect(s.hidden).toBe(false);
+ });
+
+ it("HIDE does not shield the skipped steps, nor SKIP the hidden flag", () => {
+ const afterHide = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "HIDE" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["source"] } },
+ ],
+ ready(),
+ );
+ expect(afterHide.skipped).toEqual(["source"]);
+
+ const afterSkip = reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "SKIP", key: "budget" },
+ { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
+ ],
+ ready(),
+ );
+ expect(afterSkip.hidden).toBe(true);
+ });
+
+ it("HIDE and SHOW do not change the load state", () => {
+ const hidden = reduce([{ type: "HIDE" }], ready());
+ expect(hidden.hidden).toBe(true);
+ expect(hidden.status).toBe("ready");
+ expect(hidden.counters).toEqual(snapshot.counters);
+ const shown = reduce([{ type: "SHOW" }], hidden);
+ expect(shown.hidden).toBe(false);
+ expect(shown.status).toBe("ready");
+ });
+
+ it("HIDE while the first load runs keeps status loading", () => {
+ const s = reduce([{ type: "LOAD_START", seq: 1 }, { type: "HIDE" }]);
+ expect(s.status).toBe("loading");
+ expect(s.hidden).toBe(true);
+ });
+});
+
+describe("onboardingChecklistReducer — skipped steps and the card latch (#396)", () => {
+ it("SKIP keeps checklist order and never duplicates a step", () => {
+ const s = reduce(
+ [
+ { type: "SKIP", key: "budget" },
+ { type: "SKIP", key: "source" },
+ { type: "SKIP", key: "budget" },
+ ],
+ ready(),
+ );
+ expect(s.skipped).toEqual(["source", "budget"]);
+ });
+
+ it("RESUME removes only its step", () => {
+ const s = reduce([{ type: "RESUME", key: "source" }], ready({ ...snapshot, skipped: ["source", "budget"] }));
+ expect(s.skipped).toEqual(["budget"]);
+ });
+
+ it("SKIP releases the card latch; RESUME, HIDE and a load do not touch it", () => {
+ const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
+ expect(latched.cardPage).toBe("/import");
+ expect(reduce([{ type: "SKIP", key: "importFolder" }], latched).cardPage).toBeNull();
+ expect(reduce([{ type: "RESUME", key: "importFolder" }], latched).cardPage).toBe("/import");
+ expect(
+ reduce(
+ [
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_DONE", seq: 2, snapshot },
+ ],
+ latched,
+ ).cardPage,
+ ).toBe("/import");
+ });
+
+ it("SET_CARD_PAGE with the same page returns the same state object", () => {
+ const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
+ expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
+ latched,
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Loader
+// ---------------------------------------------------------------------------
+
+/** A promise whose resolution the test controls. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function loaderDeps(overrides: Partial = {}) {
+ const actions: OnboardingChecklistAction[] = [];
+ const deps: OnboardingLoaderDeps = {
+ initializeGuidedPath: vi.fn(async () => ({ hidden: false })),
+ getOnboardingSnapshot: vi.fn(async () => snapshot),
+ dispatch: (action) => {
+ actions.push(action);
+ },
+ logError: vi.fn(),
+ ...overrides,
+ };
+ return { deps, actions };
+}
+
+describe("createOnboardingLoader (#396)", () => {
+ it("numbers its loads and dispatches LOAD_START then LOAD_DONE with the same number", async () => {
+ const { deps, actions } = loaderDeps();
+ const load = createOnboardingLoader(deps);
+ await load();
+ await load();
+ expect(actions).toEqual([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_DONE", seq: 1, snapshot },
+ { type: "LOAD_START", seq: 2 },
+ { type: "LOAD_DONE", seq: 2, snapshot },
+ ]);
+ });
+
+ it("the first load reads the snapshot only once rule D9 is applied", async () => {
+ const init = deferred<{ hidden: boolean }>();
+ const { deps } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
+ const load = createOnboardingLoader(deps);
+ const pending = load();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(deps.getOnboardingSnapshot).not.toHaveBeenCalled();
+ init.resolve({ hidden: true });
+ await pending;
+ expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(1);
+ });
+
+ it("two concurrent first loads (StrictMode double mount) run D9 once and share it", async () => {
+ const init = deferred<{ hidden: boolean }>();
+ const { deps, actions } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
+ const load = createOnboardingLoader(deps);
+ const first = load();
+ const second = load();
+ init.resolve({ hidden: false });
+ await Promise.all([first, second]);
+ expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
+ expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(2);
+ // Both answers are dispatched; the reducer keeps the one of load 2.
+ const final = actions.reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
+ expect(final.status).toBe("ready");
+ expect(final.loadSeq).toBe(2);
+ });
+
+ it("later loads never run D9 again", async () => {
+ const { deps } = loaderDeps();
+ const load = createOnboardingLoader(deps);
+ await load();
+ await load();
+ await load();
+ expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
+ });
+
+ it("a failed D9 is logged and the load still reads the snapshot", async () => {
+ const { deps, actions } = loaderDeps({
+ initializeGuidedPath: vi.fn(async () => {
+ throw new Error("database is locked");
+ }),
+ });
+ await createOnboardingLoader(deps)();
+ expect(deps.logError).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(deps.logError).mock.calls[0][1]).toBe("database is locked");
+ expect(actions[actions.length - 1]).toEqual({ type: "LOAD_DONE", seq: 1, snapshot });
+ });
+
+ it("a failed snapshot dispatches LOAD_ERROR with its number", async () => {
+ const { deps, actions } = loaderDeps({
+ getOnboardingSnapshot: vi.fn(async () => {
+ throw new Error("boom");
+ }),
+ });
+ await createOnboardingLoader(deps)();
+ expect(actions).toEqual([
+ { type: "LOAD_START", seq: 1 },
+ { type: "LOAD_ERROR", seq: 1 },
+ ]);
+ });
+
+ it("logs a failure only when a failure streak starts", async () => {
+ const results: Array<"ok" | "fail"> = ["fail", "fail", "fail", "ok", "fail"];
+ const { deps } = loaderDeps({
+ getOnboardingSnapshot: vi.fn(async () => {
+ if (results.shift() === "fail") throw new Error("database is locked");
+ return snapshot;
+ }),
+ });
+ const load = createOnboardingLoader(deps);
+ for (let i = 0; i < 5; i++) await load();
+ // Streak 1 (three failures) then streak 2 (one): two lines, not four.
+ expect(deps.logError).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(deps.logError).mock.calls[0]).toEqual([
+ "Onboarding: failed to load the getting-started path",
+ "database is locked",
+ ]);
+ });
+
+ it("the failure of a stale load is not logged", async () => {
+ const first = deferred();
+ let calls = 0;
+ const { deps } = loaderDeps({
+ getOnboardingSnapshot: vi.fn(() => (++calls === 1 ? first.promise : Promise.resolve(snapshot))),
+ });
+ const load = createOnboardingLoader(deps);
+ const stale = load();
+ await load();
+ first.reject(new Error("late failure"));
+ await stale;
+ expect(deps.logError).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Provider view: the path end to end, without rendering
+// ---------------------------------------------------------------------------
+
+describe("deriveOnboardingView — the path end to end (#396)", () => {
+ // Every card page of the milestone, so the latch is exercised before the
+ // card issues add their pages to CARD_PAGES.
+ const cardPages = new Set(["/import", "/transactions", "/budget"]);
+ const empty: OnboardingCounters = {
+ sources: 0,
+ imports: 0,
+ manualCategorized: 0,
+ budgetEntries: 0,
+ transactions: 0,
+ };
+
+ /** One render of the provider: derive, then store a latch that changed, as the hook does. */
+ function render(
+ state: OnboardingChecklistState,
+ pathname: string,
+ { budgetAllowed = true, budgetReady = true } = {},
+ ) {
+ const checklist = state.counters
+ ? computeChecklist(state.counters, {
+ importFolderSet: state.importFolderSet,
+ budgetAllowed,
+ skipped: state.skipped,
+ })
+ : null;
+ const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
+ const settled =
+ view.cardPage === state.cardPage
+ ? state
+ : onboardingChecklistReducer(state, { type: "SET_CARD_PAGE", cardPage: view.cardPage });
+ // The latch settles in one pass (React re-renders once after the update).
+ const again = deriveOnboardingView({ state: settled, checklist, pathname, budgetReady, cardPages });
+ expect(again.cardPage).toBe(settled.cardPage);
+ return { state: settled, view, checklist };
+ }
+
+ /** A load numbered after every previous one, answered with `snap`. */
+ function load(state: OnboardingChecklistState, snap: OnboardingSnapshot): OnboardingChecklistState {
+ const seq = state.loadSeq + 1;
+ return reduce(
+ [
+ { type: "LOAD_START", seq },
+ { type: "LOAD_DONE", seq, snapshot: snap },
+ ],
+ state,
+ );
+ }
+
+ const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
+
+ it("shows nothing before the first load", () => {
+ const { view } = render(initialOnboardingChecklistState, "/import");
+ expect(view.mode).toBe("none");
+ expect(view.nextStep).toBeNull();
+ expect(view.cardPage).toBeNull();
+ });
+
+ it("walks a new profile from the folder to the budget, the card staying when its step gets done", () => {
+ let s = load(initialOnboardingChecklistState, fresh);
+
+ // Empty dashboard: the welcome card holds the list (D11).
+ let r = render(s, "/");
+ expect(r.view.mode).toBe("none");
+ expect(r.view.nextStep?.key).toBe("importFolder");
+
+ // On /import: the card of the folder step.
+ s = load(r.state, fresh);
+ r = render(s, "/import");
+ expect(r.view.mode).toBe("card");
+ expect(r.state.cardPage).toBe("/import");
+
+ // Folder chosen, page open: next step on the same page, same card.
+ s = load(r.state, { ...fresh, importFolderSet: true });
+ r = render(s, "/import");
+ expect(r.view.nextStep?.key).toBe("source");
+ expect(r.view.mode).toBe("card");
+
+ // Import done, page open: the next step lives on /transactions, the card
+ // stays on /import to say "Done." and offer it.
+ const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
+ s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
+ r = render(s, "/import");
+ expect(r.view.nextStep?.key).toBe("manualCategory");
+ expect(r.view.mode).toBe("card");
+ expect(r.state.cardPage).toBe("/import");
+
+ // Following "Next step": the latch moves to /transactions.
+ s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
+ r = render(s, "/transactions");
+ expect(r.view.mode).toBe("card");
+ expect(r.state.cardPage).toBe("/transactions");
+
+ // A category changed by hand, page open: the card stays.
+ const categorized = { ...imported, manualCategorized: 1 };
+ s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
+ r = render(s, "/transactions");
+ expect(r.view.nextStep?.key).toBe("budget");
+ expect(r.view.mode).toBe("card");
+
+ // Elsewhere: the banner.
+ s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
+ r = render(s, "/reports");
+ expect(r.view.mode).toBe("banner");
+ expect(r.state.cardPage).toBeNull();
+
+ // The budget page, then a first amount: the card stays, in its end mode.
+ s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
+ r = render(s, "/budget");
+ expect(r.view.mode).toBe("card");
+ s = load(r.state, {
+ ...fresh,
+ importFolderSet: true,
+ counters: { ...categorized, budgetEntries: 1 },
+ });
+ r = render(s, "/budget");
+ expect(r.view.nextStep).toBeNull();
+ expect(r.checklist?.complete).toBe(true);
+ expect(r.view.mode).toBe("card");
+
+ // Anywhere else: the end message (D4).
+ r = render(r.state, "/");
+ expect(r.view.mode).toBe("complete");
+ });
+
+ it('"Skip" towards a step of the same page keeps the card', () => {
+ let r = render(load(initialOnboardingChecklistState, fresh), "/import");
+ expect(r.state.cardPage).toBe("/import");
+ const skipped = reduce([{ type: "SKIP", key: "importFolder" }], r.state);
+ expect(skipped.cardPage).toBeNull();
+ r = render(skipped, "/import");
+ expect(r.view.nextStep?.key).toBe("source");
+ expect(r.view.mode).toBe("card");
+ expect(r.state.cardPage).toBe("/import");
+ });
+
+ it('"Skip" towards a step of another page releases the card for the banner', () => {
+ const snap = {
+ ...fresh,
+ importFolderSet: true,
+ counters: { ...empty, sources: 1, transactions: 20 },
+ };
+ let r = render(load(initialOnboardingChecklistState, snap), "/import");
+ expect(r.view.nextStep?.key).toBe("import");
+ expect(r.view.mode).toBe("card");
+ r = render(reduce([{ type: "SKIP", key: "import" }], r.state), "/import");
+ expect(r.view.nextStep?.key).toBe("manualCategory");
+ expect(r.view.mode).toBe("banner");
+ expect(r.state.cardPage).toBeNull();
+ });
+
+ it('"Skip" of the last step on its page ends the path with the end message', () => {
+ const snap = {
+ ...fresh,
+ importFolderSet: true,
+ counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
+ };
+ let r = render(load(initialOnboardingChecklistState, snap), "/budget");
+ expect(r.view.mode).toBe("card");
+ r = render(reduce([{ type: "SKIP", key: "budget" }], r.state), "/budget");
+ expect(r.view.mode).toBe("complete");
+ });
+
+ it("hiding releases the card and shows nothing; showing again brings it back", () => {
+ let r = render(load(initialOnboardingChecklistState, fresh), "/import");
+ expect(r.view.mode).toBe("card");
+ r = render(reduce([{ type: "HIDE" }], r.state), "/import");
+ expect(r.view.mode).toBe("none");
+ expect(r.state.cardPage).toBeNull();
+ r = render(reduce([{ type: "SHOW" }], r.state), "/import");
+ expect(r.view.mode).toBe("card");
+ });
+
+ it("a profile hidden by rule D9 shows nothing on any page", () => {
+ const s = load(initialOnboardingChecklistState, {
+ ...fresh,
+ hidden: true,
+ counters: { ...empty, transactions: 40 },
+ });
+ for (const page of ["/", "/import", "/transactions", "/budget", "/reports"]) {
+ expect(render(s, page).view.mode).toBe("none");
+ }
+ });
+
+ it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
+ const snap = {
+ ...fresh,
+ importFolderSet: true,
+ counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
+ };
+ const s = load(initialOnboardingChecklistState, snap);
+ const r = render(s, "/budget", { budgetAllowed: false });
+ expect(r.checklist?.total).toBe(5);
+ expect(r.view.nextStep).toBeNull();
+ expect(r.state.cardPage).toBeNull();
+ expect(r.view.mode).toBe("complete");
+ });
+
+ it("a failed re-read keeps the card and the counters on screen", () => {
+ let r = render(load(initialOnboardingChecklistState, fresh), "/import");
+ const s = reduce(
+ [
+ { type: "LOAD_START", seq: r.state.loadSeq + 1 },
+ { type: "LOAD_ERROR", seq: r.state.loadSeq + 1 },
+ ],
+ r.state,
+ );
+ r = render(s, "/import");
+ expect(r.view.mode).toBe("card");
+ expect(r.checklist?.total).toBe(6);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Data-channel follower
+// ---------------------------------------------------------------------------
+
+describe("followDataChanges (#396, ADR 0022)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("waits 150 ms after an event before re-reading", () => {
+ const run = vi.fn();
+ const stop = followDataChanges(run);
+ notifyDataChanged();
+ vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
+ expect(run).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(run).toHaveBeenCalledTimes(1);
+ stop();
+ });
+
+ it("groups a burst of events into one re-read, 150 ms after the last one", () => {
+ const run = vi.fn();
+ const stop = followDataChanges(run);
+ notifyDataChanged();
+ vi.advanceTimersByTime(100);
+ notifyDataChanged();
+ vi.advanceTimersByTime(100);
+ notifyDataChanged();
+ vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
+ expect(run).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(run).toHaveBeenCalledTimes(1);
+ stop();
+ });
+
+ it("the cleanup drops a pending re-read and unsubscribes", () => {
+ const run = vi.fn();
+ const stop = followDataChanges(run);
+ notifyDataChanged();
+ stop();
+ vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
+ notifyDataChanged();
+ vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
+ expect(run).not.toHaveBeenCalled();
});
});
diff --git a/src/hooks/useOnboardingChecklist.ts b/src/hooks/useOnboardingChecklist.ts
index 21cbad1..db716c9 100644
--- a/src/hooks/useOnboardingChecklist.ts
+++ b/src/hooks/useOnboardingChecklist.ts
@@ -1,73 +1,139 @@
-import { useCallback, useEffect, useMemo, useReducer } from "react";
+import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
+import { useLocation } from "react-router-dom";
import {
- getOnboardingCounters,
+ getOnboardingSnapshot,
hideChecklist,
+ initializeGuidedPath,
+ resumeStep,
showChecklist,
+ skipStep,
type OnboardingSnapshot,
} from "../services/onboardingService";
+import { subscribeDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService";
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
+import {
+ bannerMode,
+ latchCardPage,
+ nextStep as findNextStep,
+ type BannerMode,
+} from "../utils/onboardingPath";
import { useEntitlement } from "./useEntitlement";
-import type { OnboardingCounters } from "../shared/types";
+import {
+ CHECKLIST_ITEM_KEYS,
+ type ChecklistItem,
+ type ChecklistItemKey,
+ type OnboardingCounters,
+ type OnboardingStatus,
+} from "../shared/types";
-// State of the getting-started checklist (#351). The reducer and its initial
-// state are exported so the state machine is tested without renderHook (same
-// pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`).
-
-export type OnboardingChecklistStatus = "loading" | "ready" | "error";
+// State of the getting-started path (#351, #396). One instance lives in
+// `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the
+// loader and the data-channel follower are exported so the state machine is
+// tested without renderHook (same pattern as `licenseReducer`,
+// `performSubmitKey`, `revocationCheckLauncher`).
export interface OnboardingChecklistState {
- status: OnboardingChecklistStatus;
+ /** `error` only while no load ever succeeded; a failed re-read keeps `ready`. */
+ status: OnboardingStatus;
/** Null until the first successful load. */
counters: OnboardingCounters | null;
/** True when an import folder has been chosen (read with the counters). */
importFolderSet: boolean;
- /** Dismissed by the user. Untouched by a load error. */
+ /** Hidden by the user or by rule D9. Untouched by a load error. */
hidden: boolean;
- /** Message of the last load error, null otherwise. */
- error: string | null;
+ /** Skipped steps, in checklist order. */
+ skipped: ChecklistItemKey[];
+ /** Number of the last load started: the outcome of any other load is stale. */
+ loadSeq: number;
+ /** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
+ hiddenSetAt: number;
+ /** Same, for SKIP / RESUME and the skipped steps. */
+ skippedSetAt: number;
+ /** The page that carries a step card (`latchCardPage`), null otherwise. */
+ cardPage: string | null;
}
export type OnboardingChecklistAction =
- | { type: "LOAD_START" }
- | { type: "LOAD_DONE"; snapshot: OnboardingSnapshot }
- | { type: "LOAD_ERROR"; error: string }
+ | { type: "LOAD_START"; seq: number }
+ | { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot }
+ | { type: "LOAD_ERROR"; seq: number }
| { type: "HIDE" }
- | { type: "SHOW" };
+ | { type: "SHOW" }
+ | { type: "SKIP"; key: ChecklistItemKey }
+ | { type: "RESUME"; key: ChecklistItemKey }
+ | { type: "SET_CARD_PAGE"; cardPage: string | null };
export const initialOnboardingChecklistState: OnboardingChecklistState = {
status: "loading",
counters: null,
importFolderSet: false,
hidden: false,
- error: null,
+ skipped: [],
+ loadSeq: 0,
+ hiddenSetAt: 0,
+ skippedSetAt: 0,
+ cardPage: null,
};
+/** `keys` plus `key`, kept in checklist order and without duplicates. */
+function withStep(keys: readonly ChecklistItemKey[], key: ChecklistItemKey): ChecklistItemKey[] {
+ return CHECKLIST_ITEM_KEYS.filter((k) => k === key || keys.includes(k));
+}
+
export function onboardingChecklistReducer(
state: OnboardingChecklistState,
action: OnboardingChecklistAction,
): OnboardingChecklistState {
switch (action.type) {
case "LOAD_START":
- // The previous counters stay so a refresh does not blank the list.
- return { ...state, status: "loading", error: null };
- case "LOAD_DONE":
+ // A re-read keeps what is on screen, `ready` included: only a path that
+ // never loaded shows `loading`.
+ return {
+ ...state,
+ loadSeq: action.seq,
+ status: state.status === "ready" ? "ready" : "loading",
+ };
+ case "LOAD_DONE": {
+ if (action.seq !== state.loadSeq) return state;
+ const { snapshot } = action;
return {
...state,
status: "ready",
- counters: action.snapshot.counters,
- importFolderSet: action.snapshot.importFolderSet,
- hidden: action.snapshot.hidden,
- error: null,
+ counters: snapshot.counters,
+ importFolderSet: snapshot.importFolderSet,
+ // A local action dispatched after this load started wins over its
+ // snapshot, read before the action's write (#384).
+ hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
+ skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
};
+ }
case "LOAD_ERROR":
- // `hidden` is deliberately kept: a failed read must not resurface a
- // checklist the user dismissed.
- return { ...state, status: "error", error: action.error };
+ if (action.seq !== state.loadSeq) return state;
+ // After a first success the last state stays on screen; `hidden` is never
+ // touched, so a failed read cannot resurface a path the user dismissed.
+ return { ...state, status: state.status === "ready" ? "ready" : "error" };
case "HIDE":
- return { ...state, hidden: true };
+ return { ...state, hidden: true, hiddenSetAt: state.loadSeq };
case "SHOW":
- return { ...state, hidden: false };
+ return { ...state, hidden: false, hiddenSetAt: state.loadSeq };
+ case "SKIP":
+ return {
+ ...state,
+ skipped: withStep(state.skipped, action.key),
+ skippedSetAt: state.loadSeq,
+ // "Skip" releases the card latch; the provider sets it again on the
+ // next render if the new next step is on the same page.
+ cardPage: null,
+ };
+ case "RESUME":
+ return {
+ ...state,
+ skipped: state.skipped.filter((key) => key !== action.key),
+ skippedSetAt: state.loadSeq,
+ };
+ case "SET_CARD_PAGE":
+ return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
}
}
@@ -75,34 +141,191 @@ function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+export interface OnboardingLoaderDeps {
+ initializeGuidedPath: () => Promise<{ hidden: boolean }>;
+ getOnboardingSnapshot: () => Promise;
+ dispatch: (action: OnboardingChecklistAction) => void;
+ logError: (...args: unknown[]) => void;
+}
+
/**
- * Getting-started checklist of the dashboard: counters read from the profile
- * database, dismissal preference, and the Budget entitlement that decides
- * whether the sixth row is locked.
+ * The provider's load function (#396), kept outside React so it is tested
+ * without a renderer:
+ * - every load is numbered, and the reducer drops the outcome of any load but
+ * the last one started (a stale answer never overwrites a newer one);
+ * - the first load waits for rule D9 (`initializeGuidedPath`), run once per
+ * loader: the promise is shared, so the double mount of StrictMode does not
+ * run it twice. A failed initialization is logged and the load goes on, the
+ * path showing as stored; the flag stays unset, so the next opening retries;
+ * - a failed load is logged only when it starts a failure streak: repeated
+ * re-reads must not push a real cause out of the last log lines that a
+ * feedback report carries.
*/
-export function useOnboardingChecklist() {
+export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promise {
+ let seq = 0;
+ let initialization: Promise | null = null;
+ let failing = false;
+
+ const initializeOnce = (): Promise => {
+ if (initialization === null) {
+ initialization = deps.initializeGuidedPath().then(
+ () => undefined,
+ (e: unknown) => {
+ deps.logError(
+ "Onboarding: failed to apply the first-run rule, the path shows as stored",
+ errorMessage(e),
+ );
+ },
+ );
+ }
+ return initialization;
+ };
+
+ return async () => {
+ const mine = ++seq;
+ deps.dispatch({ type: "LOAD_START", seq: mine });
+ await initializeOnce();
+ try {
+ const snapshot = await deps.getOnboardingSnapshot();
+ if (mine === seq) failing = false;
+ deps.dispatch({ type: "LOAD_DONE", seq: mine, snapshot });
+ } catch (e) {
+ if (mine === seq && !failing) {
+ failing = true;
+ deps.logError("Onboarding: failed to load the getting-started path", errorMessage(e));
+ }
+ deps.dispatch({ type: "LOAD_ERROR", seq: mine });
+ }
+ };
+}
+
+/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
+export const DATA_CHANGE_DELAY_MS = 150;
+
+/**
+ * Follows the data channel: `run` is called once `delayMs` passed without a
+ * new event. Returns the cleanup, which unsubscribes and drops a pending run.
+ */
+export function followDataChanges(
+ run: () => void,
+ delayMs: number = DATA_CHANGE_DELAY_MS,
+): () => void {
+ let timer: ReturnType | null = null;
+ const unsubscribe = subscribeDataChanged(() => {
+ if (timer !== null) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ run();
+ }, delayMs);
+ });
+ return () => {
+ unsubscribe();
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+}
+
+export interface OnboardingViewInput {
+ state: OnboardingChecklistState;
+ checklist: Checklist | null;
+ pathname: string;
+ budgetReady: boolean;
+ /** Pages that carry a card: `CARD_PAGES` when omitted, tests pass their own. */
+ cardPages?: ReadonlySet;
+}
+
+export interface OnboardingView {
+ nextStep: ChecklistItem | null;
+ /** The card latch for this render; the provider stores it when it changed. */
+ cardPage: string | null;
+ mode: BannerMode;
+}
+
+/**
+ * What one render of the provider shows, from its state: the next step, the
+ * card latch and the mode. Pure, so the provider's render logic is tested
+ * without React.
+ */
+export function deriveOnboardingView({
+ state,
+ checklist,
+ pathname,
+ budgetReady,
+ cardPages,
+}: OnboardingViewInput): OnboardingView {
+ const nextStep = checklist ? findNextStep(checklist) : null;
+ const cardPage = latchCardPage(state.cardPage, {
+ pathname,
+ nextStep,
+ hidden: state.hidden,
+ cardPages,
+ });
+ const mode = bannerMode({
+ status: state.status,
+ checklist,
+ hidden: state.hidden,
+ pathname,
+ isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
+ budgetReady,
+ cardPage,
+ });
+ return { nextStep, cardPage, mode };
+}
+
+/** What the provider shares through `useOnboarding()`. */
+export interface OnboardingValue {
+ status: OnboardingStatus;
+ checklist: Checklist | null;
+ /** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
+ counters: OnboardingCounters | null;
+ hidden: boolean;
+ budgetReady: boolean;
+ /** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
+ nextStep: ChecklistItem | null;
+ /** What the current page shows of the path (`bannerMode`). */
+ mode: BannerMode;
+ /** The page that carries a step card, null otherwise. */
+ cardPage: string | null;
+ hide: () => Promise;
+ show: () => Promise;
+ skip: (key: ChecklistItemKey) => Promise;
+ resume: (key: ChecklistItemKey) => Promise;
+ refresh: () => Promise;
+}
+
+/**
+ * Getting-started path: counters read from the profile database, hidden and
+ * skipped preferences, the Budget entitlement that decides whether the sixth
+ * row is locked, and what each page shows of it. Called once, by
+ * `OnboardingProvider`; pages read it through `useOnboarding()`.
+ *
+ * Re-read at mount, at every route change and 150 ms after a burst of data
+ * events (ADR 0022). The provider is the only writer of the onboarding
+ * preferences: its actions update the state themselves, without a re-read.
+ */
+export function useOnboardingChecklist(): OnboardingValue {
const [state, dispatch] = useReducer(
onboardingChecklistReducer,
initialOnboardingChecklistState,
);
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
+ const { pathname } = useLocation();
- const refresh = useCallback(async () => {
- dispatch({ type: "LOAD_START" });
- try {
- const snapshot = await getOnboardingCounters();
- dispatch({ type: "LOAD_DONE", snapshot });
- } catch (e) {
- const error = errorMessage(e);
- logError("Onboarding: failed to load checklist counters", error);
- dispatch({ type: "LOAD_ERROR", error });
- }
- }, []);
+ // One loader per provider instance: a profile switch remounts AppShell, so
+ // the new profile gets its own numbering and its own D9 initialization.
+ const [refresh] = useState(() =>
+ createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
+ );
- // Read-only local SELECTs: a StrictMode double run is harmless.
+ // Read at mount, then again at every route change.
useEffect(() => {
void refresh();
- }, [refresh]);
+ }, [refresh, pathname]);
+
+ // Re-read after the writes that move a counter, once a burst is over.
+ useEffect(() => followDataChanges(() => void refresh()), [refresh]);
const hide = useCallback(async () => {
try {
@@ -122,28 +345,80 @@ export function useOnboardingChecklist() {
}
}, []);
+ const skip = useCallback(async (key: ChecklistItemKey) => {
+ try {
+ await skipStep(key);
+ dispatch({ type: "SKIP", key });
+ } catch (e) {
+ logError("Onboarding: failed to skip a step", errorMessage(e));
+ }
+ }, []);
+
+ const resume = useCallback(async (key: ChecklistItemKey) => {
+ try {
+ await resumeStep(key);
+ dispatch({ type: "RESUME", key });
+ } catch (e) {
+ logError("Onboarding: failed to resume a step", errorMessage(e));
+ }
+ }, []);
+
const checklist: Checklist | null = useMemo(
() =>
state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed,
+ skipped: state.skipped,
})
: null,
- [state.counters, state.importFolderSet, budgetAllowed],
+ [state.counters, state.importFolderSet, state.skipped, budgetAllowed],
);
- return {
- status: state.status,
+ const { nextStep, cardPage, mode } = deriveOnboardingView({
+ state,
checklist,
- /** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
- counters: state.counters,
- hidden: state.hidden,
- /** Message of the last load error, null otherwise. */
- error: state.error,
+ pathname,
budgetReady,
- refresh,
- hide,
- show,
- };
+ });
+ // The card latch is derived during render, then stored: an effect would
+ // leave one frame showing the banner on a page that carries a card. The
+ // update is for this component's own reducer, which React applies before
+ // committing; `latchCardPage` is idempotent, so it settles in one pass.
+ if (cardPage !== state.cardPage) {
+ dispatch({ type: "SET_CARD_PAGE", cardPage });
+ }
+
+ return useMemo(
+ () => ({
+ status: state.status,
+ checklist,
+ counters: state.counters,
+ hidden: state.hidden,
+ budgetReady,
+ nextStep,
+ mode,
+ cardPage,
+ hide,
+ show,
+ skip,
+ resume,
+ refresh,
+ }),
+ [
+ state.status,
+ checklist,
+ state.counters,
+ state.hidden,
+ budgetReady,
+ nextStep,
+ mode,
+ cardPage,
+ hide,
+ show,
+ skip,
+ resume,
+ refresh,
+ ],
+ );
}
diff --git a/src/pages/DashboardPage.render.test.tsx b/src/pages/DashboardPage.render.test.tsx
new file mode 100644
index 0000000..9416fd0
--- /dev/null
+++ b/src/pages/DashboardPage.render.test.tsx
@@ -0,0 +1,143 @@
+// DashboardPage reads the shared getting-started path (#396). No jsdom:
+// react-dom/server renders in vitest's node environment. The path comes from a
+// fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed
+// to markers: what is pinned is the page's branching on the path, unchanged
+// since #352 except that its source is now the provider.
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { MemoryRouter } from "react-router-dom";
+import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
+}));
+
+let onboarding: OnboardingValue;
+vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
+
+vi.mock("../hooks/useDashboard", () => ({
+ useDashboard: () => ({
+ state: {
+ categoryBreakdown: [],
+ categoryOverTime: { categories: [], data: [] },
+ cartesSnapshot: null,
+ period: "year",
+ customDateFrom: "2026-01-01",
+ customDateTo: "2026-10-08",
+ referenceYear: 2026,
+ referenceMonth: 9,
+ accountIds: [],
+ accounts: [],
+ netWorth: null,
+ isLoading: false,
+ error: null,
+ },
+ setPeriod: vi.fn(),
+ setCustomDates: vi.fn(),
+ setReferencePeriod: vi.fn(),
+ setAccountIds: vi.fn(),
+ }),
+}));
+
+// Hoisted with the mocks below, which run before the imports.
+const { marker } = vi.hoisted(() => ({
+ marker: (name: string) => ({
+ default: (props: { mode?: string }) => ,
+ }),
+}));
+vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
+vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
+vi.mock("../components/dashboard/DashboardEmptyState", () => marker("empty-state"));
+vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist"));
+vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
+vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
+vi.mock("../components/reports/CategoryBarChart", () => marker("bar-chart"));
+vi.mock("../components/reports/CategoryOverTimeChart", () => marker("over-time"));
+vi.mock("../components/reports/FilterPanel", () => marker("filters"));
+vi.mock("../components/reports/cards/KpiCard", () => marker("kpi"));
+vi.mock("../components/reports/cards/IncomeExpenseOverlayChart", () => marker("overlay"));
+vi.mock("../components/reports/cards/TopMoversList", () => marker("movers"));
+vi.mock("../components/reports/cards/BudgetAdherenceCard", () => marker("adherence"));
+vi.mock("../components/shared/TransactionDetailModal", () => marker("detail"));
+
+import DashboardPage from "./DashboardPage";
+import { computeChecklist } from "../utils/onboardingChecklist";
+import type { OnboardingCounters } from "../shared/types";
+
+const counters = (transactions: number): OnboardingCounters => ({
+ sources: transactions > 0 ? 1 : 0,
+ imports: transactions > 0 ? 1 : 0,
+ manualCategorized: 0,
+ budgetEntries: 0,
+ transactions,
+});
+
+function path(overrides: Partial & { transactions?: number } = {}): OnboardingValue {
+ const { transactions = 25, ...rest } = overrides;
+ const c = counters(transactions);
+ return {
+ status: "ready",
+ checklist: computeChecklist(c, { importFolderSet: true, budgetAllowed: true }),
+ counters: c,
+ hidden: false,
+ budgetReady: true,
+ nextStep: null,
+ mode: "banner",
+ cardPage: null,
+ hide: vi.fn(async () => undefined),
+ show: vi.fn(async () => undefined),
+ skip: vi.fn(async () => undefined),
+ resume: vi.fn(async () => undefined),
+ refresh: vi.fn(async () => undefined),
+ ...rest,
+ };
+}
+
+const render = () =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+beforeEach(() => {
+ onboarding = path();
+});
+
+describe("DashboardPage on the shared path (#352, #396)", () => {
+ it("renders only its header while the path first loads", () => {
+ onboarding = path({ status: "loading", checklist: null, counters: null });
+ const html = render();
+ expect(html).toContain("dashboard.title");
+ expect(html).not.toContain('data-marker="empty-state"');
+ expect(html).not.toContain('data-marker="filters"');
+ });
+
+ it("shows the welcome card on a profile without any transaction", () => {
+ onboarding = path({ transactions: 0 });
+ const html = render();
+ expect(html).toContain('data-marker="empty-state"');
+ expect(html).not.toContain('data-marker="filters"');
+ });
+
+ it("shows the compact checklist above the widgets on a profile with transactions", () => {
+ const html = render();
+ expect(html).toContain('data-marker="checklist" data-mode="compact"');
+ expect(html).toContain('data-marker="filters"');
+ });
+
+ it("hides the compact checklist of a hidden path (a profile already active, D9)", () => {
+ onboarding = path({ hidden: true, mode: "none" });
+ const html = render();
+ expect(html).not.toContain('data-marker="checklist"');
+ expect(html).toContain('data-marker="filters"');
+ });
+
+ it("renders the page without the list when the first load failed", () => {
+ onboarding = path({ status: "error", checklist: null, counters: null, mode: "none" });
+ const html = render();
+ expect(html).not.toContain('data-marker="checklist"');
+ expect(html).not.toContain('data-marker="empty-state"');
+ expect(html).toContain('data-marker="filters"');
+ });
+});
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index f68fecc..d8dda50 100644
--- a/src/pages/DashboardPage.tsx
+++ b/src/pages/DashboardPage.tsx
@@ -14,13 +14,15 @@
// profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With
// transactions, the compact checklist sits between the title and the filter
-// panel until it is complete or dismissed. A load error falls back to the
-// v0.15.0 page without the list.
-import { useState, useCallback, useMemo, useEffect } from "react";
+// panel until it is complete or dismissed. A first load that fails falls back
+// to the v0.15.0 page without the list; the provider logs it.
+//
+// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
+// by every page and re-read live, instead of a load of its own at mount.
+import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useDashboard } from "../hooks/useDashboard";
-import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
-import { logWarn } from "../services/logService";
+import { useOnboarding } from "../contexts/OnboardingContext";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart";
@@ -48,13 +50,7 @@ export default function DashboardPage() {
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
- const onboarding = useOnboardingChecklist();
-
- useEffect(() => {
- if (onboarding.status === "error") {
- logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
- }
- }, [onboarding.status, onboarding.error]);
+ const onboarding = useOnboarding();
const [hiddenCategories, setHiddenCategories] = useState>(new Set());
const [detailModal, setDetailModal] = useState(null);
diff --git a/src/services/onboardingService.test.ts b/src/services/onboardingService.test.ts
index fb58ebb..883e953 100644
--- a/src/services/onboardingService.test.ts
+++ b/src/services/onboardingService.test.ts
@@ -14,19 +14,21 @@ vi.mock("./logService", () => ({
logError: vi.fn(),
}));
-import { getDb } from "./db";
+import { getDb, withTransaction } from "./db";
import { logInfo } from "./logService";
+import { subscribeDataChanged } from "./dataEvents";
import {
ONBOARDING_CHECKLIST_HIDDEN_KEY,
- getOnboardingCounters,
+ ONBOARDING_GUIDED_INITIALIZED_KEY,
+ getOnboardingSnapshot,
hideChecklist,
+ initializeGuidedPath,
+ resumeStep,
showChecklist,
+ skipStep,
+ skippedStepKey,
} from "./onboardingService";
-const mockSelect = vi.fn();
-const mockExecute = vi.fn();
-const mockDb = { select: mockSelect, execute: mockExecute };
-
const countersRow = {
sources: 2,
imports: 3,
@@ -35,31 +37,85 @@ const countersRow = {
transactions: 60,
};
+interface FakeDbOptions {
+ prefs?: Record;
+ /** Rows of the aggregated counters query; null = the driver returns no row. */
+ counters?: typeof countersRow | null;
+ /** Answer of `SELECT COUNT(*) AS transactions` (rule D9). */
+ transactions?: number;
+ /** Makes the matching `execute` reject, as SQLite would. */
+ failOn?: (sql: string, params: unknown[]) => boolean;
+}
+
/**
- * Routes each SELECT by its SQL: the aggregated counters query, or a
- * `user_preferences` lookup answered from `prefs`.
+ * Just enough of `user_preferences` and of a transaction to watch what the
+ * service writes: the preferences live in a map, BEGIN keeps a copy that
+ * ROLLBACK restores, and every `execute` is logged.
*/
-function wireSelect(prefs: Record, row: unknown | null = countersRow) {
- mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => {
- if (sql.includes("user_preferences")) {
+function makeFakeDb(options: FakeDbOptions = {}) {
+ let prefs: Record = { ...options.prefs };
+ let beforeTransaction: Record | null = null;
+ const statements: string[] = [];
+
+ const select = vi.fn(async (sql: string, params?: unknown[]) => {
+ if (sql.includes("COUNT(*) AS transactions FROM transactions")) {
+ return [{ transactions: options.transactions ?? 0 }];
+ }
+ if (sql.includes("LIKE 'onboarding_skipped_%'")) {
+ return Object.entries(prefs)
+ .filter(([key]) => /^onboarding.skipped./.test(key))
+ .map(([key, value]) => ({ key, value }));
+ }
+ if (sql.includes("FROM user_preferences WHERE key = $1")) {
const key = String(params?.[0]);
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
}
- return row === null ? [] : [row];
+ if (sql.includes("FROM import_sources")) {
+ const row = options.counters === undefined ? countersRow : options.counters;
+ return row === null ? [] : [row];
+ }
+ throw new Error(`unexpected SELECT: ${sql}`);
});
+
+ const execute = vi.fn(async (sql: string, params: unknown[] = []) => {
+ statements.push(sql.trim().split(/\s+/).slice(0, 3).join(" "));
+ if (options.failOn?.(sql, params)) throw new Error("database is locked");
+ if (sql === "BEGIN") {
+ beforeTransaction = { ...prefs };
+ } else if (sql === "COMMIT") {
+ beforeTransaction = null;
+ } else if (sql === "ROLLBACK") {
+ if (beforeTransaction) prefs = beforeTransaction;
+ beforeTransaction = null;
+ } else if (sql.includes("INSERT INTO user_preferences")) {
+ prefs[String(params[0])] = String(params[1]);
+ } else if (sql.includes("DELETE FROM user_preferences")) {
+ delete prefs[String(params[0])];
+ } else {
+ throw new Error(`unexpected execute: ${sql}`);
+ }
+ return { rowsAffected: 1 };
+ });
+
+ return { db: { select, execute }, statements, prefs: () => prefs };
+}
+
+function useFakeDb(options: FakeDbOptions = {}) {
+ const fake = makeFakeDb(options);
+ vi.mocked(getDb).mockResolvedValue(fake.db as never);
+ return fake;
}
beforeEach(() => {
- vi.mocked(getDb).mockResolvedValue(mockDb as never);
- mockSelect.mockReset();
- mockExecute.mockReset();
+ vi.mocked(getDb).mockReset();
+ vi.mocked(withTransaction).mockClear();
vi.mocked(logInfo).mockReset();
});
-describe("getOnboardingCounters (#351)", () => {
+describe("getOnboardingSnapshot (#351, #396)", () => {
it("maps the aggregated row to camelCase counters", async () => {
- wireSelect({});
- const snapshot = await getOnboardingCounters();
+ useFakeDb();
+ const snapshot = await getOnboardingSnapshot();
expect(snapshot.counters).toEqual({
sources: 2,
imports: 3,
@@ -69,54 +125,67 @@ describe("getOnboardingCounters (#351)", () => {
});
});
- it("runs the five sub-counts in one query plus the two preference reads", async () => {
- wireSelect({});
- await getOnboardingCounters();
- expect(mockSelect).toHaveBeenCalledTimes(3);
- const sqls = mockSelect.mock.calls.map((c) => String(c[0]));
+ it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
+ const { db } = useFakeDb();
+ await getOnboardingSnapshot();
+ expect(db.select).toHaveBeenCalledTimes(4);
+ const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
expect(aggregate).toContain("FROM budget_entries");
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
- const prefKeys = mockSelect.mock.calls
- .filter((c) => String(c[0]).includes("user_preferences"))
+ const prefKeys = db.select.mock.calls
+ .filter((c) => String(c[0]).includes("WHERE key = $1"))
.map((c) => (c[1] as unknown[])[0]);
expect(prefKeys).toEqual(
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
);
+ expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
});
- it("reports importFolderSet=false and hidden=false without preferences", async () => {
- wireSelect({});
- const snapshot = await getOnboardingCounters();
+ it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
+ const { db } = useFakeDb({ transactions: 60 });
+ const unsubscribe = subscribeDataChanged(() => {
+ throw new Error("a snapshot must not announce anything");
+ });
+ try {
+ await getOnboardingSnapshot();
+ } finally {
+ unsubscribe();
+ }
+ expect(db.execute).not.toHaveBeenCalled();
+ expect(withTransaction).not.toHaveBeenCalled();
+ });
+
+ it("reports importFolderSet=false, hidden=false and no skipped step without preferences", async () => {
+ useFakeDb();
+ const snapshot = await getOnboardingSnapshot();
expect(snapshot.importFolderSet).toBe(false);
expect(snapshot.hidden).toBe(false);
+ expect(snapshot.skipped).toEqual([]);
});
it("reports importFolderSet=true when a folder is stored", async () => {
- wireSelect({ import_folder: "/home/max/releves" });
- const snapshot = await getOnboardingCounters();
- expect(snapshot.importFolderSet).toBe(true);
+ useFakeDb({ prefs: { import_folder: "/home/max/releves" } });
+ expect((await getOnboardingSnapshot()).importFolderSet).toBe(true);
});
it("treats an empty import_folder value as unset", async () => {
- wireSelect({ import_folder: "" });
- const snapshot = await getOnboardingCounters();
- expect(snapshot.importFolderSet).toBe(false);
+ useFakeDb({ prefs: { import_folder: "" } });
+ expect((await getOnboardingSnapshot()).importFolderSet).toBe(false);
});
it('reports hidden=true only for the value "1"', async () => {
- wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" });
- expect((await getOnboardingCounters()).hidden).toBe(true);
- wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" });
- expect((await getOnboardingCounters()).hidden).toBe(false);
+ useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
+ expect((await getOnboardingSnapshot()).hidden).toBe(true);
+ useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" } });
+ expect((await getOnboardingSnapshot()).hidden).toBe(false);
});
it("falls back to zero counters when the driver returns no row", async () => {
- wireSelect({}, null);
- const snapshot = await getOnboardingCounters();
- expect(snapshot.counters).toEqual({
+ useFakeDb({ counters: null });
+ expect((await getOnboardingSnapshot()).counters).toEqual({
sources: 0,
imports: 0,
manualCategorized: 0,
@@ -124,32 +193,214 @@ describe("getOnboardingCounters (#351)", () => {
transactions: 0,
});
});
+
+ it("reads the skipped steps in checklist order", async () => {
+ useFakeDb({
+ prefs: {
+ [skippedStepKey("budget")]: "1",
+ [skippedStepKey("source")]: "1",
+ },
+ });
+ 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 () => {
+ useFakeDb({
+ prefs: {
+ onboarding_skipped_dragons: "1",
+ onboarding_skipped_: "1",
+ onboarding_skipped_import_extra: "1",
+ onboardingXskippedYimport: "1",
+ [skippedStepKey("import")]: "1",
+ },
+ });
+ expect((await getOnboardingSnapshot()).skipped).toEqual(["import"]);
+ });
+
+ it('counts a skipped step only for the value "1"', async () => {
+ useFakeDb({
+ prefs: {
+ [skippedStepKey("source")]: "0",
+ [skippedStepKey("import")]: "true",
+ [skippedStepKey("budget")]: "",
+ [skippedStepKey("manualCategory")]: "1",
+ },
+ });
+ expect((await getOnboardingSnapshot()).skipped).toEqual(["manualCategory"]);
+ });
+});
+
+describe("initializeGuidedPath — rule D9 (#396)", () => {
+ it("without the flag and with transactions: hides, then flags, inside one transaction", async () => {
+ const fake = useFakeDb({ transactions: 3 });
+ await expect(initializeGuidedPath()).resolves.toEqual({ hidden: true });
+
+ expect(withTransaction).toHaveBeenCalledTimes(1);
+ const writes = fake.db.execute.mock.calls.map(([sql, params]) => [
+ String(sql).trim().split(/\s+/)[0],
+ (params as unknown[] | undefined)?.[0],
+ ]);
+ expect(writes).toEqual([
+ ["BEGIN", undefined],
+ ["INSERT", ONBOARDING_CHECKLIST_HIDDEN_KEY],
+ ["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
+ ["COMMIT", undefined],
+ ]);
+ // The flag and the transaction count are read after BEGIN.
+ const firstSelect = fake.db.select.mock.invocationCallOrder[0];
+ expect(firstSelect).toBeGreaterThan(fake.db.execute.mock.invocationCallOrder[0]);
+ expect(fake.prefs()).toEqual({
+ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
+ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
+ });
+ expect(logInfo).toHaveBeenCalledTimes(1);
+ });
+
+ it("without the flag and without transactions: flags only, the path stays visible", async () => {
+ const fake = useFakeDb({ transactions: 0 });
+ await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
+ expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "COMMIT"]);
+ expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
+ expect(logInfo).not.toHaveBeenCalled();
+ });
+
+ it("with the flag already set: no write at all, even with transactions", async () => {
+ const fake = useFakeDb({
+ transactions: 3,
+ prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
+ });
+ await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
+ expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
+ expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
+ expect(logInfo).not.toHaveBeenCalled();
+ });
+
+ it("a profile shown again after D9 is not hidden a second time", async () => {
+ // D9 hid it, the user showed it again from Settings → Users.
+ const fake = useFakeDb({ transactions: 3 });
+ await initializeGuidedPath();
+ await showChecklist();
+ await initializeGuidedPath();
+ expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
+ });
+
+ it("a failed hiding write rolls back and leaves the flag unset", async () => {
+ const fake = useFakeDb({
+ transactions: 3,
+ failOn: (sql, params) =>
+ sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_CHECKLIST_HIDDEN_KEY,
+ });
+ await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
+ expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "ROLLBACK"]);
+ expect(fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY]).toBeUndefined();
+ expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
+ expect(logInfo).not.toHaveBeenCalled();
+ });
+
+ it("a failed flag write rolls the hiding back too", async () => {
+ const fake = useFakeDb({
+ transactions: 3,
+ failOn: (sql, params) =>
+ sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_GUIDED_INITIALIZED_KEY,
+ });
+ await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
+ expect(fake.statements[fake.statements.length - 1]).toBe("ROLLBACK");
+ expect(fake.prefs()).toEqual({});
+ });
+
+ it("the next snapshot comes back hidden", async () => {
+ useFakeDb({ transactions: 3, counters: { ...countersRow, transactions: 3 } });
+ expect((await getOnboardingSnapshot()).hidden).toBe(false);
+ await initializeGuidedPath();
+ expect((await getOnboardingSnapshot()).hidden).toBe(true);
+ });
+
+ it("does not announce anything on the data channel", async () => {
+ useFakeDb({ transactions: 3 });
+ const listener = vi.fn();
+ const unsubscribe = subscribeDataChanged(listener);
+ try {
+ await initializeGuidedPath();
+ } finally {
+ unsubscribe();
+ }
+ expect(listener).not.toHaveBeenCalled();
+ });
});
describe("hideChecklist / showChecklist (#351)", () => {
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
- mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
+ const fake = useFakeDb();
await hideChecklist();
- expect(mockExecute).toHaveBeenCalledTimes(1);
- const [sql, params] = mockExecute.mock.calls[0];
+ expect(fake.db.execute).toHaveBeenCalledTimes(1);
+ const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("INSERT INTO user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
expect(logInfo).toHaveBeenCalledTimes(1);
});
it("showChecklist deletes the preference and logs", async () => {
- mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
+ const fake = useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
await showChecklist();
- expect(mockExecute).toHaveBeenCalledTimes(1);
- const [sql, params] = mockExecute.mock.calls[0];
+ expect(fake.db.execute).toHaveBeenCalledTimes(1);
+ const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("DELETE FROM user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
expect(logInfo).toHaveBeenCalledTimes(1);
});
it("does not log when the write fails", async () => {
- mockExecute.mockRejectedValueOnce(new Error("database is locked"));
+ useFakeDb({ failOn: () => true });
await expect(hideChecklist()).rejects.toThrow("database is locked");
expect(logInfo).not.toHaveBeenCalled();
});
});
+
+describe("skipStep / resumeStep (#396, D3)", () => {
+ it('skipStep writes "1" under onboarding_skipped_, without reading first', async () => {
+ const fake = useFakeDb();
+ await skipStep("budget");
+ expect(fake.db.select).not.toHaveBeenCalled();
+ expect(fake.db.execute).toHaveBeenCalledTimes(1);
+ expect(fake.db.execute.mock.calls[0][1]).toEqual(["onboarding_skipped_budget", "1"]);
+ expect(fake.prefs()).toEqual({ onboarding_skipped_budget: "1" });
+ });
+
+ it("skipStep is idempotent", async () => {
+ const fake = useFakeDb();
+ await skipStep("source");
+ await skipStep("source");
+ expect(fake.prefs()).toEqual({ onboarding_skipped_source: "1" });
+ });
+
+ it("resumeStep deletes the preference, and is idempotent", async () => {
+ const fake = useFakeDb({ prefs: { onboarding_skipped_source: "1", onboarding_skipped_budget: "1" } });
+ await resumeStep("source");
+ await resumeStep("source");
+ expect(fake.prefs()).toEqual({ onboarding_skipped_budget: "1" });
+ expect(String(fake.db.execute.mock.calls[0][0])).toContain("DELETE FROM user_preferences");
+ });
+
+ it("skip then resume round-trips through the snapshot", async () => {
+ useFakeDb();
+ await skipStep("manualCategory");
+ expect((await getOnboardingSnapshot()).skipped).toEqual(["manualCategory"]);
+ await resumeStep("manualCategory");
+ expect((await getOnboardingSnapshot()).skipped).toEqual([]);
+ });
+
+ it("neither announces on the data channel", async () => {
+ useFakeDb();
+ const listener = vi.fn();
+ const unsubscribe = subscribeDataChanged(listener);
+ try {
+ await skipStep("budget");
+ await resumeStep("budget");
+ await hideChecklist();
+ await showChecklist();
+ } finally {
+ unsubscribe();
+ }
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/services/onboardingService.ts b/src/services/onboardingService.ts
index 2807da4..d56d11d 100644
--- a/src/services/onboardingService.ts
+++ b/src/services/onboardingService.ts
@@ -1,4 +1,4 @@
-import { getDb } from "./db";
+import { getDb, withTransaction } from "./db";
import {
deletePreference,
getImportFolder,
@@ -6,26 +6,44 @@ import {
setPreference,
} from "./userPreferenceService";
import { logInfo } from "./logService";
-import type { OnboardingCounters } from "../shared/types";
+import {
+ CHECKLIST_ITEM_KEYS,
+ type ChecklistItemKey,
+ type OnboardingCounters,
+} from "../shared/types";
/**
- * Getting-started checklist data (#351).
+ * Getting-started path data (#351, #396).
*
- * Everything the checklist needs is read in a single round of parallel reads:
- * one aggregated query for the counters, the import folder preference and the
- * "hidden" preference. No UI lives here; `computeChecklist` turns the result
- * into rows and `useOnboardingChecklist` holds the state.
+ * `getOnboardingSnapshot` reads everything the path needs in one round of
+ * parallel reads, and writes nothing: the counters, the import folder, the
+ * "hidden" preference and the skipped steps. The writes are separate, and the
+ * onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
+ * `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
+ * announced on the data channel: the provider updates its own state. No UI
+ * lives here; `computeChecklist` turns the snapshot into rows and
+ * `useOnboardingChecklist` holds the state.
*/
-/** `user_preferences` key set to "1" when the user dismissed the checklist. */
+/** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
+/** `user_preferences` key set to "1" once rule D9 ran for the profile. */
+export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized";
+
+/** `user_preferences` key of a skipped step (D3): `onboarding_skipped_`, value "1". */
+export function skippedStepKey(key: ChecklistItemKey): string {
+ return `onboarding_skipped_${key}`;
+}
+
export interface OnboardingSnapshot {
counters: OnboardingCounters;
/** True when an import folder has been chosen. */
importFolderSet: boolean;
- /** True when the checklist was dismissed by the user. */
+ /** True when the path is hidden (by the user, or by rule D9). */
hidden: boolean;
+ /** Skipped steps, in checklist order: only the six keys, only the value "1". */
+ skipped: ChecklistItemKey[];
}
interface CountersRow {
@@ -45,6 +63,15 @@ const COUNTERS_SQL = `
(SELECT COUNT(*) FROM transactions) AS transactions
`;
+// `_` is a LIKE wildcard: the pattern may match more than the prefix, and the
+// exact-key filter of `readSkippedSteps` is what decides.
+const SKIPPED_SQL =
+ "SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
+
+const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
+ VALUES ($1, $2, CURRENT_TIMESTAMP)
+ ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
+
async function readCounters(): Promise {
const db = await getDb();
const rows = await db.select(COUNTERS_SQL);
@@ -58,28 +85,105 @@ async function readCounters(): Promise {
};
}
-/** Reads the counters, the import folder and the hidden flag in parallel. */
-export async function getOnboardingCounters(): Promise {
- const [counters, importFolder, hiddenPref] = await Promise.all([
+/** Skipped steps, read in one query and kept only for a valid key holding "1". */
+async function readSkippedSteps(): Promise {
+ const db = await getDb();
+ const rows = await db.select>(SKIPPED_SQL);
+ const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
+ return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
+}
+
+/**
+ * Reads the counters, the import folder, the hidden flag and the skipped steps
+ * in parallel. A pure read: it never writes (rule D9 lives in
+ * `initializeGuidedPath`).
+ */
+export async function getOnboardingSnapshot(): Promise {
+ const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
readCounters(),
getImportFolder(),
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
+ readSkippedSteps(),
]);
return {
counters,
importFolderSet: importFolder !== null && importFolder !== "",
hidden: hiddenPref === "1",
+ skipped,
};
}
-/** Dismisses the checklist until `showChecklist()` is called. */
+/**
+ * Rule D9, once per profile: at the first opening under this version, a
+ * profile that already has transactions gets the path hidden (same preference
+ * as "Hide", so Settings → Users shows it again); an empty or new profile sees
+ * it. Runs as one transaction under the DB lock, flag re-read inside it, so two
+ * concurrent first loads cannot apply it twice (CWE-362): the hidden preference
+ * is written first, then the flag, then COMMIT; any failure rolls both back and
+ * leaves the flag unset, so the next opening tries again.
+ *
+ * Returns `{ hidden: true }` only when this call hid the path, which it also
+ * writes to the in-app log. Not announced on the data channel.
+ */
+export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
+ const hidden = await withTransaction(async (db) => {
+ await db.execute("BEGIN");
+ try {
+ const flag = await db.select>(
+ "SELECT value FROM user_preferences WHERE key = $1",
+ [ONBOARDING_GUIDED_INITIALIZED_KEY],
+ );
+ if (flag[0]?.value === "1") {
+ await db.execute("COMMIT");
+ return false;
+ }
+ const rows = await db.select>(
+ "SELECT COUNT(*) AS transactions FROM transactions",
+ );
+ const hasTransactions = (rows[0]?.transactions ?? 0) > 0;
+ if (hasTransactions) {
+ await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
+ }
+ await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_GUIDED_INITIALIZED_KEY, "1"]);
+ await db.execute("COMMIT");
+ return hasTransactions;
+ } catch (e) {
+ try {
+ await db.execute("ROLLBACK");
+ } catch {
+ // Keep the original error: it is the one that explains the failure.
+ }
+ throw e;
+ }
+ });
+ if (hidden) {
+ logInfo(
+ "Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again)",
+ );
+ }
+ return { hidden };
+}
+
+/** Hides the path until `showChecklist()` is called. */
export async function hideChecklist(): Promise {
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
logInfo("Onboarding: checklist hidden");
}
-/** Shows the checklist again (removes the dismissal preference). */
+/** Shows the path again (removes the hidden preference). */
export async function showChecklist(): Promise {
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
logInfo("Onboarding: checklist shown");
}
+
+/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
+export async function skipStep(key: ChecklistItemKey): Promise {
+ await setPreference(skippedStepKey(key), "1");
+ logInfo(`Onboarding: step ${key} skipped`);
+}
+
+/** Cancels a skip. Idempotent: deleting a missing preference changes nothing. */
+export async function resumeStep(key: ChecklistItemKey): Promise {
+ await deletePreference(skippedStepKey(key));
+ logInfo(`Onboarding: step ${key} resumed`);
+}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index 0bb858b..0fad653 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -974,11 +974,30 @@ export type ChecklistItemKey =
| "manualCategory"
| "budget";
+/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
+export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
+ "profile",
+ "importFolder",
+ "source",
+ "import",
+ "manualCategory",
+ "budget",
+];
+
/** One row of the getting-started checklist. `to` is the route the row links to. */
export interface ChecklistItem {
key: ChecklistItemKey;
+ /** The data says the step is done. */
done: boolean;
+ /** The user skipped the step (#396, D3). A row can be done and skipped. */
+ skipped: boolean;
/** True when the feature behind the row is not part of the current edition. */
locked: boolean;
to: string;
}
+
+/**
+ * Load status of the getting-started path (#396). `error` only before a first
+ * successful load: a later failed re-read keeps `ready` and the last state.
+ */
+export type OnboardingStatus = "loading" | "ready" | "error";
diff --git a/src/utils/onboardingChecklist.test.ts b/src/utils/onboardingChecklist.test.ts
index 13dc401..19b913e 100644
--- a/src/utils/onboardingChecklist.test.ts
+++ b/src/utils/onboardingChecklist.test.ts
@@ -130,3 +130,59 @@ describe("computeChecklist — budget gating (#351)", () => {
expect(list.complete).toBe(true);
});
});
+
+describe("computeChecklist — skipped steps (#396, D3)", () => {
+ const fiveDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
+
+ it("no row is skipped when the option is omitted or empty", () => {
+ expect(computeChecklist(zero, open).items.every((i) => !i.skipped)).toBe(true);
+ expect(computeChecklist(zero, { ...open, skipped: [] }).items.every((i) => !i.skipped)).toBe(
+ true,
+ );
+ });
+
+ it("a skipped row is marked skipped, not done, and counts in the counter", () => {
+ const list = computeChecklist(zero, { ...open, skipped: ["importFolder"] });
+ const row = list.items.find((i) => i.key === "importFolder")!;
+ expect(row.skipped).toBe(true);
+ expect(row.done).toBe(false);
+ expect(list.done).toBe(2); // profile + the skipped row
+ expect(list.total).toBe(6);
+ });
+
+ it("complete with done and skipped rows", () => {
+ const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
+ expect(list.done).toBe(6);
+ expect(list.total).toBe(6);
+ expect(list.complete).toBe(true);
+ });
+
+ it("a locked row stays out of the total, even skipped", () => {
+ const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
+ const budget = list.items.find((i) => i.key === "budget")!;
+ expect(budget.locked).toBe(true);
+ expect(list.total).toBe(5);
+ expect(list.done).toBe(1);
+ expect(list.complete).toBe(false);
+ });
+
+ it("a row both done and skipped counts once", () => {
+ const list = computeChecklist(fiveDone, {
+ ...open,
+ importFolderSet: true,
+ skipped: ["source", "import", "budget"],
+ });
+ const source = list.items.find((i) => i.key === "source")!;
+ expect(source.done).toBe(true);
+ expect(source.skipped).toBe(true);
+ expect(list.done).toBe(6);
+ expect(list.total).toBe(6);
+ });
+
+ it("skipping does not change the rows' links", () => {
+ const to = computeChecklist(zero, { ...open, skipped: ["manualCategory", "budget"] }).items.map(
+ (i) => i.to,
+ );
+ expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
+ });
+});
diff --git a/src/utils/onboardingChecklist.ts b/src/utils/onboardingChecklist.ts
index 4105e37..069a8f8 100644
--- a/src/utils/onboardingChecklist.ts
+++ b/src/utils/onboardingChecklist.ts
@@ -1,4 +1,4 @@
-import type { ChecklistItem, OnboardingCounters } from "../shared/types";
+import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
/**
* Pure computation of the getting-started checklist (#351).
@@ -7,6 +7,10 @@ import type { ChecklistItem, OnboardingCounters } from "../shared/types";
* is only reachable from an open profile). The `budget` row is locked when the
* edition does not include Budget; a locked row is excluded from `total` and
* from `complete`, and links to the Users settings (license card) instead.
+ *
+ * A row can also be skipped by the user (#396, D3). A skipped row counts like a
+ * done one, once even when it is both, unless it is locked: a locked row stays
+ * out of the total whatever its state.
*/
export interface ChecklistOptions {
@@ -14,15 +18,17 @@ export interface ChecklistOptions {
importFolderSet: boolean;
/** The Budget feature is part of the current edition. */
budgetAllowed: boolean;
+ /** Steps the user skipped (#396). None when omitted. */
+ skipped?: readonly ChecklistItemKey[];
}
export interface Checklist {
items: ChecklistItem[];
- /** Number of unlocked rows that are done. */
+ /** Number of unlocked rows that are done or skipped. */
done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */
total: number;
- /** True when every unlocked row is done. */
+ /** True when every unlocked row is done or skipped. */
complete: boolean;
}
@@ -31,29 +37,32 @@ export function computeChecklist(
options: ChecklistOptions
): Checklist {
const budgetLocked = !options.budgetAllowed;
+ const skipped = new Set(options.skipped ?? []);
+ const row = (
+ key: ChecklistItemKey,
+ done: boolean,
+ to: string,
+ locked = false,
+ ): ChecklistItem => ({ key, done, skipped: skipped.has(key), locked, to });
+
const items: ChecklistItem[] = [
// Always done: the checklist only exists inside an open profile. The link
// is never rendered for a done row (#352); it points at profile management.
- { key: "profile", done: true, locked: false, to: "/settings/users" },
- { key: "importFolder", done: options.importFolderSet, locked: false, to: "/import" },
- { key: "source", done: counters.sources >= 1, locked: false, to: "/import" },
- { key: "import", done: counters.imports >= 1, locked: false, to: "/import" },
- {
- key: "manualCategory",
- done: counters.manualCategorized >= 1,
- locked: false,
- to: "/transactions",
- },
- {
- key: "budget",
- done: counters.budgetEntries >= 1,
- locked: budgetLocked,
- to: budgetLocked ? "/settings/users" : "/budget",
- },
+ row("profile", true, "/settings/users"),
+ row("importFolder", options.importFolderSet, "/import"),
+ row("source", counters.sources >= 1, "/import"),
+ row("import", counters.imports >= 1, "/import"),
+ row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
+ row(
+ "budget",
+ counters.budgetEntries >= 1,
+ budgetLocked ? "/settings/users" : "/budget",
+ budgetLocked,
+ ),
];
const unlocked = items.filter((item) => !item.locked);
- const done = unlocked.filter((item) => item.done).length;
+ const done = unlocked.filter((item) => item.done || item.skipped).length;
const total = unlocked.length;
return { items, done, total, complete: done === total };
diff --git a/src/utils/onboardingPath.test.ts b/src/utils/onboardingPath.test.ts
new file mode 100644
index 0000000..0728868
--- /dev/null
+++ b/src/utils/onboardingPath.test.ts
@@ -0,0 +1,310 @@
+import { describe, it, expect } from "vitest";
+import { computeChecklist, type Checklist } from "./onboardingChecklist";
+import {
+ CARD_PAGES,
+ bannerMode,
+ latchCardPage,
+ nextStep,
+ readOnboardingNavState,
+ stepsOfPage,
+ type BannerModeInput,
+} from "./onboardingPath";
+import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
+
+const zero: OnboardingCounters = {
+ sources: 0,
+ imports: 0,
+ manualCategorized: 0,
+ budgetEntries: 0,
+ transactions: 0,
+};
+
+/** Steps 2 to 5 done (folder, source, import, manual category). */
+const upToManual: OnboardingCounters = {
+ ...zero,
+ sources: 1,
+ imports: 1,
+ manualCategorized: 1,
+ transactions: 20,
+};
+
+const checklistOf = (
+ counters: OnboardingCounters,
+ options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {},
+): Checklist =>
+ computeChecklist(counters, {
+ importFolderSet: options.importFolderSet ?? false,
+ budgetAllowed: options.budgetAllowed ?? true,
+ skipped: options.skipped,
+ });
+
+/** A row of the given step, as `computeChecklist` builds it. */
+const step = (key: ChecklistItemKey): ChecklistItem =>
+ checklistOf(zero).items.find((item) => item.key === key)!;
+
+const ALL_CARD_PAGES: ReadonlySet = new Set(["/import", "/transactions", "/budget"]);
+
+describe("nextStep (#396)", () => {
+ it("is the folder step on a new profile", () => {
+ expect(nextStep(checklistOf(zero))?.key).toBe("importFolder");
+ });
+
+ it("skips the done rows", () => {
+ expect(nextStep(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))?.key).toBe(
+ "import",
+ );
+ });
+
+ it("skips the skipped rows", () => {
+ expect(nextStep(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import");
+ });
+
+ it("skips a locked row: Free edition with every other step done → null", () => {
+ expect(
+ nextStep(checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false })),
+ ).toBeNull();
+ });
+
+ it("returns null at the end", () => {
+ expect(
+ nextStep(checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true })),
+ ).toBeNull();
+ expect(
+ nextStep(checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] })),
+ ).toBeNull();
+ });
+
+ it("links through ChecklistItem.to", () => {
+ expect(nextStep(checklistOf(upToManual, { importFolderSet: true }))).toMatchObject({
+ key: "budget",
+ to: "/budget",
+ });
+ expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
+ .toMatchObject({ key: "manualCategory", to: "/transactions" });
+ });
+});
+
+describe("stepsOfPage (#396)", () => {
+ it("maps each guided page to its steps, in checklist order", () => {
+ expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
+ expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
+ expect(stepsOfPage("/budget")).toEqual(["budget"]);
+ });
+
+ it("gives no step to any other page, inherited object keys included", () => {
+ for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) {
+ expect(stepsOfPage(page)).toEqual([]);
+ }
+ });
+
+ it("every card page guides at least one step", () => {
+ for (const page of CARD_PAGES) expect(stepsOfPage(page).length).toBeGreaterThan(0);
+ });
+});
+
+describe("latchCardPage (#396)", () => {
+ const latch = (
+ previous: string | null,
+ pathname: string,
+ next: ChecklistItem | null,
+ hidden = false,
+ ) => latchCardPage(previous, { pathname, nextStep: next, hidden, cardPages: ALL_CARD_PAGES });
+
+ it("is set on the next step's page when that page carries a card", () => {
+ expect(latch(null, "/import", step("importFolder"))).toBe("/import");
+ expect(latch(null, "/transactions", step("manualCategory"))).toBe("/transactions");
+ });
+
+ it("is not set on the next step's page when that page carries no card", () => {
+ expect(
+ latchCardPage(null, {
+ pathname: "/import",
+ nextStep: step("importFolder"),
+ hidden: false,
+ cardPages: new Set(["/budget"]),
+ }),
+ ).toBeNull();
+ });
+
+ it("follows CARD_PAGES by default", () => {
+ for (const [page, key] of [
+ ["/import", "importFolder"],
+ ["/transactions", "manualCategory"],
+ ["/budget", "budget"],
+ ] as const) {
+ const latched = latchCardPage(null, { pathname: page, nextStep: step(key), hidden: false });
+ expect(latched).toBe(CARD_PAGES.has(page) ? page : null);
+ }
+ });
+
+ it("is not set on a page that is not the next step's", () => {
+ expect(latch(null, "/budget", step("importFolder"))).toBeNull();
+ expect(latch(null, "/", step("importFolder"))).toBeNull();
+ });
+
+ it("is not set at the end of the path", () => {
+ expect(latch(null, "/import", null)).toBeNull();
+ });
+
+ it("is released when the path is hidden, even on its page", () => {
+ expect(latch("/import", "/import", step("importFolder"), true)).toBeNull();
+ expect(latch(null, "/import", step("importFolder"), true)).toBeNull();
+ });
+
+ it("holds when the step gets done with the page open, the next step being elsewhere", () => {
+ expect(latch("/transactions", "/transactions", step("budget"))).toBe("/transactions");
+ });
+
+ it("holds when the last step gets done with the page open", () => {
+ expect(latch("/budget", "/budget", null)).toBe("/budget");
+ });
+
+ it("is released by navigation, then set again only on the next step's card page", () => {
+ expect(latch("/import", "/reports", step("manualCategory"))).toBeNull();
+ expect(latch("/import", "/budget", step("manualCategory"))).toBeNull();
+ expect(latch("/import", "/transactions", step("manualCategory"))).toBe("/transactions");
+ });
+
+ it('after "Skip" (latch cleared), it is set again when the new next step is on the same page', () => {
+ // Skipping the folder step on /import: the source step is on /import too.
+ expect(latch(null, "/import", step("source"))).toBe("/import");
+ });
+
+ it('after "Skip" (latch cleared), it stays released when the new next step is on another page', () => {
+ // Skipping the import step on /import: the manual category is on /transactions.
+ expect(latch(null, "/import", step("manualCategory"))).toBeNull();
+ });
+
+ it("is idempotent", () => {
+ const cases: Array<[string | null, string, ChecklistItem | null, boolean]> = [
+ [null, "/import", step("importFolder"), false],
+ ["/import", "/import", step("budget"), false],
+ ["/import", "/transactions", step("manualCategory"), false],
+ ["/import", "/reports", step("manualCategory"), false],
+ ["/import", "/import", step("importFolder"), true],
+ [null, "/budget", null, false],
+ ];
+ for (const [previous, pathname, next, hidden] of cases) {
+ const once = latch(previous, pathname, next, hidden);
+ expect(latch(once, pathname, next, hidden)).toBe(once);
+ }
+ });
+});
+
+describe("bannerMode (#396)", () => {
+ const midway = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true });
+ const complete = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
+ const base: BannerModeInput = {
+ status: "ready",
+ checklist: midway,
+ hidden: false,
+ pathname: "/reports",
+ isEmptyDashboard: false,
+ budgetReady: true,
+ cardPage: null,
+ };
+ const mode = (overrides: Partial) => bannerMode({ ...base, ...overrides });
+
+ it("shows the banner on an ordinary page", () => {
+ expect(mode({})).toBe("banner");
+ });
+
+ it("shows nothing during the first load, or after a first load that failed", () => {
+ expect(mode({ status: "loading", checklist: null })).toBe("none");
+ expect(mode({ status: "error", checklist: null })).toBe("none");
+ expect(mode({ status: "loading" })).toBe("none");
+ });
+
+ it("keeps the banner after a failed re-read (the reducer keeps status ready and the last state)", () => {
+ expect(mode({ status: "ready", checklist: midway })).toBe("banner");
+ });
+
+ it("shows nothing when the path is hidden, card page included", () => {
+ expect(mode({ hidden: true })).toBe("none");
+ expect(mode({ hidden: true, pathname: "/import", cardPage: "/import" })).toBe("none");
+ expect(mode({ hidden: true, checklist: complete })).toBe("none");
+ });
+
+ it("shows nothing on the empty dashboard, whose welcome card holds the list (D11)", () => {
+ expect(mode({ pathname: "/", isEmptyDashboard: true })).toBe("none");
+ });
+
+ it("shows the banner on a dashboard with transactions", () => {
+ expect(mode({ pathname: "/" })).toBe("banner");
+ });
+
+ it("shows the card on the latched page", () => {
+ expect(mode({ pathname: "/import", cardPage: "/import" })).toBe("card");
+ });
+
+ it("shows the banner on a page that carries no card", () => {
+ expect(mode({ pathname: "/import", cardPage: null })).toBe("banner");
+ });
+
+ it("shows the banner while a latch from another page has not been released yet", () => {
+ expect(mode({ pathname: "/transactions", cardPage: "/import" })).toBe("banner");
+ });
+
+ it("keeps the card once the step is done with the page open, end of the path included", () => {
+ expect(mode({ pathname: "/budget", cardPage: "/budget", checklist: complete })).toBe("card");
+ });
+
+ it("shows the end message when every step is done or skipped (D4)", () => {
+ expect(mode({ checklist: complete })).toBe("complete");
+ const skippedEnd = checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] });
+ expect(mode({ checklist: skippedEnd })).toBe("complete");
+ });
+
+ 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.
+ const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
+ expect(failClosed.complete).toBe(true);
+ expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
+ expect(mode({ checklist: failClosed, budgetReady: true })).toBe("complete");
+ });
+
+ it("shows the banner before the Budget entitlement is known when steps remain", () => {
+ expect(mode({ budgetReady: false })).toBe("banner");
+ });
+});
+
+describe("readOnboardingNavState (#396)", () => {
+ const none = { focus: false, guided: false };
+
+ it("reads anything that is not an object as no request", () => {
+ expect(readOnboardingNavState(null)).toEqual(none);
+ expect(readOnboardingNavState(undefined)).toEqual(none);
+ expect(readOnboardingNavState("onboarding")).toEqual(none);
+ expect(readOnboardingNavState(42)).toEqual(none);
+ expect(readOnboardingNavState(true)).toEqual(none);
+ });
+
+ it("reads a partial or malformed object as no request", () => {
+ expect(readOnboardingNavState({})).toEqual(none);
+ expect(readOnboardingNavState([])).toEqual(none);
+ expect(readOnboardingNavState({ onboarding: null })).toEqual(none);
+ expect(readOnboardingNavState({ onboarding: "focus" })).toEqual(none);
+ expect(readOnboardingNavState({ onboarding: {} })).toEqual(none);
+ expect(readOnboardingNavState({ onboarding: { focus: "yes" } })).toEqual(none);
+ expect(readOnboardingNavState({ onboarding: { focus: 1, guided: "true" } })).toEqual(none);
+ expect(readOnboardingNavState({ focus: true, guided: true })).toEqual(none);
+ });
+
+ it("reads the valid requests", () => {
+ expect(readOnboardingNavState({ onboarding: { focus: true } })).toEqual({
+ focus: true,
+ guided: false,
+ });
+ expect(readOnboardingNavState({ onboarding: { guided: true } })).toEqual({
+ focus: false,
+ guided: true,
+ });
+ expect(readOnboardingNavState({ onboarding: { focus: true, guided: true } })).toEqual({
+ focus: true,
+ guided: true,
+ });
+ expect(
+ readOnboardingNavState({ from: "/", onboarding: { focus: true, guided: false, extra: 1 } }),
+ ).toEqual({ focus: true, guided: false });
+ });
+});
diff --git a/src/utils/onboardingPath.ts b/src/utils/onboardingPath.ts
new file mode 100644
index 0000000..0e8fef4
--- /dev/null
+++ b/src/utils/onboardingPath.ts
@@ -0,0 +1,130 @@
+import type { Checklist } from "./onboardingChecklist";
+import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types";
+
+/**
+ * Pure rules of the guided getting-started path (#396).
+ *
+ * The provider (`useOnboardingChecklist`) feeds them its state; the banner and
+ * the step cards of the next issues only read what they decide. Nothing here
+ * touches React, the router or the database.
+ */
+
+/**
+ * The next step: the first unlocked row neither done nor skipped, or null when
+ * there is none left. Its link is `ChecklistItem.to`.
+ */
+export function nextStep(checklist: Checklist): ChecklistItem | null {
+ return checklist.items.find((item) => !item.locked && !item.done && !item.skipped) ?? null;
+}
+
+/**
+ * Routes whose page carries a step card. Empty in this issue: each card issue
+ * adds its page, and a page without a delivered card keeps the banner, so a
+ * partly merged stack never removes any guidance.
+ */
+export const CARD_PAGES: ReadonlySet = new Set();
+
+const PAGE_STEPS: Readonly> = {
+ "/import": ["importFolder", "source", "import"],
+ "/transactions": ["manualCategory"],
+ "/budget": ["budget"],
+};
+
+/** The steps a page guides, in checklist order (none for any other page). */
+export function stepsOfPage(pathname: string): readonly ChecklistItemKey[] {
+ return Object.prototype.hasOwnProperty.call(PAGE_STEPS, pathname) ? PAGE_STEPS[pathname] : [];
+}
+
+export interface CardLatchInput {
+ pathname: string;
+ nextStep: ChecklistItem | null;
+ hidden: boolean;
+ /** Pages that carry a card. Defaults to `CARD_PAGES`; tests pass their own. */
+ cardPages?: ReadonlySet;
+}
+
+/**
+ * The card latch: the page that carries a step card, or null.
+ *
+ * - It is set when `pathname` is the route of the next step and is in the
+ * card pages.
+ * - It is released when the route changes, when the path is hidden, and after
+ * "Skip" when the next step is no longer on this page: the skip action clears
+ * the latch (`previous` = null), and this function sets it again only if the
+ * new next step is on the same page.
+ * - A step that gets done does not release it: the card stays to say "Done."
+ * and offer the next step, even when that step lives on another page.
+ *
+ * Idempotent: applying it to its own result changes nothing, which lets the
+ * provider re-run it on every render.
+ */
+export function latchCardPage(previous: string | null, input: CardLatchInput): string | null {
+ const { pathname, nextStep: next, hidden } = input;
+ const cardPages = input.cardPages ?? CARD_PAGES;
+ if (hidden) return null;
+ if (previous !== null && previous === pathname) return previous;
+ if (next !== null && next.to === pathname && cardPages.has(pathname)) return pathname;
+ return null;
+}
+
+export type BannerMode = "none" | "banner" | "card" | "complete";
+
+export interface BannerModeInput {
+ status: OnboardingStatus;
+ checklist: Checklist | null;
+ hidden: boolean;
+ pathname: string;
+ /** The dashboard of a profile without any transaction (D11). */
+ isEmptyDashboard: boolean;
+ /** False until the Budget entitlement is known. */
+ budgetReady: boolean;
+ /** The latched card page (`latchCardPage`). */
+ cardPage: string | null;
+}
+
+/**
+ * What the path shows on the current page:
+ * - `none` before the first successful load, when the path is hidden (D8, D9),
+ * and on the empty dashboard, whose welcome card already holds the list (D11);
+ * - `card` on the latched card page, even once every step is done (the card
+ * then says so itself);
+ * - `complete` when every unlocked step is done or skipped (D4), and only once
+ * Budget's entitlement is known: until then the checklist is computed with
+ * Budget locked, and "complete" could be a sixth step away;
+ * - `banner` everywhere else. Its counter waits for `budgetReady` too.
+ *
+ * A failed re-read after a first success keeps status `ready` (reducer), so
+ * the last state stays on screen.
+ */
+export function bannerMode(input: BannerModeInput): BannerMode {
+ const { status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage } = input;
+ if (status !== "ready" || checklist === null) return "none";
+ if (hidden) return "none";
+ if (isEmptyDashboard) return "none";
+ if (cardPage !== null && cardPage === pathname) return "card";
+ if (checklist.complete) return budgetReady ? "complete" : "none";
+ return "banner";
+}
+
+/** What a page may read from `location.state` about the path. */
+export interface OnboardingNavState {
+ /** Move the focus to the step card's title. */
+ focus: boolean;
+ /** Open the page in its guided entry (Transactions: uncategorized only). */
+ guided: boolean;
+}
+
+/**
+ * The only way to read `location.state` (typed `any` by react-router) for the
+ * path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
+ * `{ onboarding: { guided: true } }` reads as false, whatever its shape.
+ */
+export function readOnboardingNavState(state: unknown): OnboardingNavState {
+ if (typeof state !== "object" || state === null) return { focus: false, guided: false };
+ const onboarding: unknown = (state as { onboarding?: unknown }).onboarding;
+ if (typeof onboarding !== "object" || onboarding === null) {
+ return { focus: false, guided: false };
+ }
+ const flags = onboarding as { focus?: unknown; guided?: unknown };
+ return { focus: flags.focus === true, guided: flags.guided === true };
+}