feat(onboarding): share the getting-started path through a provider

The checklist state moves from DashboardPage to OnboardingProvider, mounted
in AppShell around the routed page; pages read it through useOnboarding().
The reducer stays in useOnboardingChecklist:

- loads are numbered and a stale answer is ignored;
- HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started
  before them (#384);
- a refresh keeps the counters and the ready status, and a failed re-read
  after a first success keeps the last state; only the start of a failure
  streak is logged;
- re-read at mount, on every route change and 150 ms after a burst of
  data events.

onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and
is a pure read (skipped steps included, one preference per step, filtered on
the six keys and the value "1"); initializeGuidedPath applies rule D9 once
per profile in one transaction (hidden first, then the flag, ROLLBACK on
failure), awaited once by the first load through a promise shared by
StrictMode's double mount; skipStep / resumeStep are idempotent.

computeChecklist counts skipped rows; the new pure onboardingPath module
holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch,
bannerMode and readOnboardingNavState. The dashboard reads the context and
keeps its compact bar for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 07:33:57 -04:00
parent 4febed9eab
commit dcf3b013dc
14 changed files with 2206 additions and 202 deletions

View file

@ -1,12 +1,16 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import { OnboardingProvider } from "../../contexts/OnboardingContext";
export default function AppShell() { export default function AppShell() {
return ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<Sidebar /> <Sidebar />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
<Outlet /> {/* The getting-started path, shared by every page (#396). */}
<OnboardingProvider>
<Outlet />
</OnboardingProvider>
</main> </main>
</div> </div>
); );

View file

@ -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: () => <nav>sidebar</nav> }));
import AppShell from "../components/layout/AppShell";
import { OnboardingProvider, useOnboarding } from "./OnboardingContext";
function Probe() {
const onboarding = useOnboarding();
return (
<output>
{[
onboarding.status,
onboarding.mode,
String(onboarding.cardPage),
String(onboarding.nextStep),
String(onboarding.checklist),
typeof onboarding.skip,
typeof onboarding.refresh,
].join("|")}
</output>
);
}
describe("OnboardingProvider (#396)", () => {
it("useOnboarding throws outside the provider", () => {
expect(() =>
renderToStaticMarkup(
<MemoryRouter>
<Probe />
</MemoryRouter>,
),
).toThrow("useOnboarding must be used within OnboardingProvider");
});
it("shares the path, still loading on the first render", () => {
const html = renderToStaticMarkup(
<MemoryRouter initialEntries={["/import"]}>
<OnboardingProvider>
<Probe />
</OnboardingProvider>
</MemoryRouter>,
);
expect(html).toContain("loading|none|null|null|null|function|function");
});
it("AppShell mounts the provider around the routed page", () => {
const html = renderToStaticMarkup(
<MemoryRouter initialEntries={["/transactions"]}>
<Routes>
<Route element={<AppShell />}>
<Route path="/transactions" element={<Probe />} />
</Route>
</Routes>
</MemoryRouter>,
);
expect(html).toContain("sidebar");
expect(html).toContain("<main");
expect(html).toContain("loading|none|");
});
});

View file

@ -0,0 +1,26 @@
import { createContext, useContext, type ReactNode } from "react";
import { useOnboardingChecklist, type OnboardingValue } from "../hooks/useOnboardingChecklist";
/**
* The getting-started path, shared by every page (#396).
*
* A thin provider: the state lives in the reducer of `useOnboardingChecklist`
* (one `useReducer` hook per domain), which this component calls once. It is
* mounted in `AppShell`, around the routed page, so it survives navigation and
* re-reads on each route change; a profile switch remounts AppShell
* (`BrowserRouter key={refreshKey}`) and with it a fresh provider, whose
* cleanups drop the previous profile's subscription and timer.
*/
const OnboardingContext = createContext<OnboardingValue | null>(null);
export function OnboardingProvider({ children }: { children: ReactNode }) {
const value = useOnboardingChecklist();
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
}
/** 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;
}

View file

@ -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): // 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 // The hook module imports the onboarding service (→ db → tauri plugins) and
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both // useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
@ -20,15 +20,24 @@ vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
import { import {
onboardingChecklistReducer, onboardingChecklistReducer,
initialOnboardingChecklistState, initialOnboardingChecklistState,
createOnboardingLoader,
deriveOnboardingView,
followDataChanges,
DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction, type OnboardingChecklistAction,
type OnboardingChecklistState, type OnboardingChecklistState,
type OnboardingLoaderDeps,
} from "./useOnboardingChecklist"; } from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents";
import { computeChecklist } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService"; import type { OnboardingSnapshot } from "../services/onboardingService";
import type { OnboardingCounters } from "../shared/types";
const snapshot: OnboardingSnapshot = { const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true, importFolderSet: true,
hidden: false, hidden: false,
skipped: [],
}; };
const reduce = ( const reduce = (
@ -36,87 +45,688 @@ const reduce = (
from: OnboardingChecklistState = initialOnboardingChecklistState, from: OnboardingChecklistState = initialOnboardingChecklistState,
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from); ): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
describe("onboardingChecklistReducer (#351)", () => { /** A first successful load (seq 1). */
it("starts loading with no counters and not hidden", () => { 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({ expect(initialOnboardingChecklistState).toEqual({
status: "loading", status: "loading",
counters: null, counters: null,
importFolderSet: false, importFolderSet: false,
hidden: false, hidden: false,
error: null, skipped: [],
loadSeq: 0,
hiddenSetAt: 0,
skippedSetAt: 0,
cardPage: null,
}); });
}); });
it("LOAD_DONE stores the snapshot and sets ready", () => { 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.status).toBe("ready");
expect(s.counters).toEqual(snapshot.counters); expect(s.counters).toEqual(snapshot.counters);
expect(s.importFolderSet).toBe(true); expect(s.importFolderSet).toBe(true);
expect(s.hidden).toBe(false); expect(s.hidden).toBe(false);
expect(s.error).toBeNull(); expect(s.skipped).toEqual(["budget"]);
}); });
it("LOAD_DONE applies the persisted hidden flag", () => { it("LOAD_DONE applies the persisted hidden flag", () => {
const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]); expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
expect(s.hidden).toBe(true);
}); });
it("LOAD_ERROR sets status=error with the message and keeps hidden", () => { it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
const hiddenReady = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]); 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( const s = reduce(
[{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }], [
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_ERROR", seq: 2 },
],
hiddenReady, hiddenReady,
); );
expect(s.status).toBe("error"); expect(s.status).toBe("ready");
expect(s.error).toBe("database is locked");
expect(s.hidden).toBe(true);
// The last known counters survive a failed refresh.
expect(s.counters).toEqual(snapshot.counters); 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", () => { it("a first load that fails sets status=error and keeps hidden=false", () => {
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]); const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
expect(s.status).toBe("error"); expect(s.status).toBe("error");
expect(s.hidden).toBe(false); expect(s.hidden).toBe(false);
expect(s.counters).toBeNull(); expect(s.counters).toBeNull();
}); });
it("refresh after an error goes back to loading, then ready", () => { it("a stale LOAD_ERROR is ignored", () => {
const errored = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]); const s = reduce([
const loading = reduce([{ type: "LOAD_START" }], errored); { type: "LOAD_START", seq: 1 },
expect(loading.status).toBe("loading"); { type: "LOAD_START", seq: 2 },
expect(loading.error).toBeNull(); { type: "LOAD_ERROR", seq: 1 },
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" }]);
expect(s.status).toBe("loading"); 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", () => { it("never mutates the previous state", () => {
const before = reduce([{ type: "LOAD_DONE", snapshot }]); const before = ready();
const frozen = Object.freeze({ ...before }); const frozen = Object.freeze({ ...before });
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow(); expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
expect(frozen.hidden).toBe(false); 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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function loaderDeps(overrides: Partial<OnboardingLoaderDeps> = {}) {
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<OnboardingSnapshot>();
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();
}); });
}); });

View file

@ -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 { import {
getOnboardingCounters, getOnboardingSnapshot,
hideChecklist, hideChecklist,
initializeGuidedPath,
resumeStep,
showChecklist, showChecklist,
skipStep,
type OnboardingSnapshot, type OnboardingSnapshot,
} from "../services/onboardingService"; } from "../services/onboardingService";
import { subscribeDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService"; import { logError } from "../services/logService";
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist"; import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
import {
bannerMode,
latchCardPage,
nextStep as findNextStep,
type BannerMode,
} from "../utils/onboardingPath";
import { useEntitlement } from "./useEntitlement"; 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 of the getting-started path (#351, #396). One instance lives in
// state are exported so the state machine is tested without renderHook (same // `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the
// pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`). // loader and the data-channel follower are exported so the state machine is
// tested without renderHook (same pattern as `licenseReducer`,
export type OnboardingChecklistStatus = "loading" | "ready" | "error"; // `performSubmitKey`, `revocationCheckLauncher`).
export interface OnboardingChecklistState { 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. */ /** Null until the first successful load. */
counters: OnboardingCounters | null; counters: OnboardingCounters | null;
/** True when an import folder has been chosen (read with the counters). */ /** True when an import folder has been chosen (read with the counters). */
importFolderSet: boolean; 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; hidden: boolean;
/** Message of the last load error, null otherwise. */ /** Skipped steps, in checklist order. */
error: string | null; 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 = export type OnboardingChecklistAction =
| { type: "LOAD_START" } | { type: "LOAD_START"; seq: number }
| { type: "LOAD_DONE"; snapshot: OnboardingSnapshot } | { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot }
| { type: "LOAD_ERROR"; error: string } | { type: "LOAD_ERROR"; seq: number }
| { type: "HIDE" } | { 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 = { export const initialOnboardingChecklistState: OnboardingChecklistState = {
status: "loading", status: "loading",
counters: null, counters: null,
importFolderSet: false, importFolderSet: false,
hidden: 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( export function onboardingChecklistReducer(
state: OnboardingChecklistState, state: OnboardingChecklistState,
action: OnboardingChecklistAction, action: OnboardingChecklistAction,
): OnboardingChecklistState { ): OnboardingChecklistState {
switch (action.type) { switch (action.type) {
case "LOAD_START": case "LOAD_START":
// The previous counters stay so a refresh does not blank the list. // A re-read keeps what is on screen, `ready` included: only a path that
return { ...state, status: "loading", error: null }; // never loaded shows `loading`.
case "LOAD_DONE": 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 { return {
...state, ...state,
status: "ready", status: "ready",
counters: action.snapshot.counters, counters: snapshot.counters,
importFolderSet: action.snapshot.importFolderSet, importFolderSet: snapshot.importFolderSet,
hidden: action.snapshot.hidden, // A local action dispatched after this load started wins over its
error: null, // 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": case "LOAD_ERROR":
// `hidden` is deliberately kept: a failed read must not resurface a if (action.seq !== state.loadSeq) return state;
// checklist the user dismissed. // After a first success the last state stays on screen; `hidden` is never
return { ...state, status: "error", error: action.error }; // touched, so a failed read cannot resurface a path the user dismissed.
return { ...state, status: state.status === "ready" ? "ready" : "error" };
case "HIDE": case "HIDE":
return { ...state, hidden: true }; return { ...state, hidden: true, hiddenSetAt: state.loadSeq };
case "SHOW": 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); return e instanceof Error ? e.message : String(e);
} }
export interface OnboardingLoaderDeps {
initializeGuidedPath: () => Promise<{ hidden: boolean }>;
getOnboardingSnapshot: () => Promise<OnboardingSnapshot>;
dispatch: (action: OnboardingChecklistAction) => void;
logError: (...args: unknown[]) => void;
}
/** /**
* Getting-started checklist of the dashboard: counters read from the profile * The provider's load function (#396), kept outside React so it is tested
* database, dismissal preference, and the Budget entitlement that decides * without a renderer:
* whether the sixth row is locked. * - 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<void> {
let seq = 0;
let initialization: Promise<void> | null = null;
let failing = false;
const initializeOnce = (): Promise<void> => {
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<typeof setTimeout> | 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<string>;
}
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<void>;
show: () => Promise<void>;
skip: (key: ChecklistItemKey) => Promise<void>;
resume: (key: ChecklistItemKey) => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Getting-started path: counters read from the profile database, hidden and
* skipped preferences, the Budget entitlement that decides whether the sixth
* 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( const [state, dispatch] = useReducer(
onboardingChecklistReducer, onboardingChecklistReducer,
initialOnboardingChecklistState, initialOnboardingChecklistState,
); );
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget"); const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
const { pathname } = useLocation();
const refresh = useCallback(async () => { // One loader per provider instance: a profile switch remounts AppShell, so
dispatch({ type: "LOAD_START" }); // the new profile gets its own numbering and its own D9 initialization.
try { const [refresh] = useState(() =>
const snapshot = await getOnboardingCounters(); createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
dispatch({ type: "LOAD_DONE", snapshot }); );
} catch (e) {
const error = errorMessage(e);
logError("Onboarding: failed to load checklist counters", error);
dispatch({ type: "LOAD_ERROR", error });
}
}, []);
// Read-only local SELECTs: a StrictMode double run is harmless. // Read at mount, then again at every route change.
useEffect(() => { useEffect(() => {
void refresh(); 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 () => { const hide = useCallback(async () => {
try { 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( const checklist: Checklist | null = useMemo(
() => () =>
state.counters state.counters
? computeChecklist(state.counters, { ? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet, importFolderSet: state.importFolderSet,
budgetAllowed, budgetAllowed,
skipped: state.skipped,
}) })
: null, : null,
[state.counters, state.importFolderSet, budgetAllowed], [state.counters, state.importFolderSet, state.skipped, budgetAllowed],
); );
return { const { nextStep, cardPage, mode } = deriveOnboardingView({
status: state.status, state,
checklist, checklist,
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */ pathname,
counters: state.counters,
hidden: state.hidden,
/** Message of the last load error, null otherwise. */
error: state.error,
budgetReady, budgetReady,
refresh, });
hide, // The card latch is derived during render, then stored: an effect would
show, // 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,
],
);
} }

View file

@ -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 }) => <i data-marker={name} data-mode={props.mode} />,
}),
}));
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<OnboardingValue> & { 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(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
);
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"');
});
});

View file

@ -14,13 +14,15 @@
// profile without any transaction gets `DashboardEmptyState` instead of the // profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With // widgets, outside the `isLoading` veil so its link stays clickable. With
// transactions, the compact checklist sits between the title and the filter // 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 // panel until it is complete or dismissed. A first load that fails falls back
// v0.15.0 page without the list. // to the v0.15.0 page without the list; the provider logs it.
import { useState, useCallback, useMemo, useEffect } from "react"; //
// 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 { useTranslation } from "react-i18next";
import { useDashboard } from "../hooks/useDashboard"; import { useDashboard } from "../hooks/useDashboard";
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist"; import { useOnboarding } from "../contexts/OnboardingContext";
import { logWarn } from "../services/logService";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
@ -48,13 +50,7 @@ export default function DashboardPage() {
setAccountIds, setAccountIds,
} = useDashboard(); } = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state; const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const onboarding = useOnboardingChecklist(); const onboarding = useOnboarding();
useEffect(() => {
if (onboarding.status === "error") {
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
}
}, [onboarding.status, onboarding.error]);
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);

View file

@ -14,19 +14,21 @@ vi.mock("./logService", () => ({
logError: vi.fn(), logError: vi.fn(),
})); }));
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import { logInfo } from "./logService"; import { logInfo } from "./logService";
import { subscribeDataChanged } from "./dataEvents";
import { import {
ONBOARDING_CHECKLIST_HIDDEN_KEY, ONBOARDING_CHECKLIST_HIDDEN_KEY,
getOnboardingCounters, ONBOARDING_GUIDED_INITIALIZED_KEY,
getOnboardingSnapshot,
hideChecklist, hideChecklist,
initializeGuidedPath,
resumeStep,
showChecklist, showChecklist,
skipStep,
skippedStepKey,
} from "./onboardingService"; } from "./onboardingService";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
const countersRow = { const countersRow = {
sources: 2, sources: 2,
imports: 3, imports: 3,
@ -35,31 +37,85 @@ const countersRow = {
transactions: 60, transactions: 60,
}; };
interface FakeDbOptions {
prefs?: Record<string, string>;
/** 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 * Just enough of `user_preferences` and of a transaction to watch what the
* `user_preferences` lookup answered from `prefs`. * service writes: the preferences live in a map, BEGIN keeps a copy that
* ROLLBACK restores, and every `execute` is logged.
*/ */
function wireSelect(prefs: Record<string, string>, row: unknown | null = countersRow) { function makeFakeDb(options: FakeDbOptions = {}) {
mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => { let prefs: Record<string, string> = { ...options.prefs };
if (sql.includes("user_preferences")) { let beforeTransaction: Record<string, string> | 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]); const key = String(params?.[0]);
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : []; return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
} }
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(() => { beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never); vi.mocked(getDb).mockReset();
mockSelect.mockReset(); vi.mocked(withTransaction).mockClear();
mockExecute.mockReset();
vi.mocked(logInfo).mockReset(); vi.mocked(logInfo).mockReset();
}); });
describe("getOnboardingCounters (#351)", () => { describe("getOnboardingSnapshot (#351, #396)", () => {
it("maps the aggregated row to camelCase counters", async () => { it("maps the aggregated row to camelCase counters", async () => {
wireSelect({}); useFakeDb();
const snapshot = await getOnboardingCounters(); const snapshot = await getOnboardingSnapshot();
expect(snapshot.counters).toEqual({ expect(snapshot.counters).toEqual({
sources: 2, sources: 2,
imports: 3, imports: 3,
@ -69,54 +125,67 @@ describe("getOnboardingCounters (#351)", () => {
}); });
}); });
it("runs the five sub-counts in one query plus the two preference reads", async () => { it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
wireSelect({}); const { db } = useFakeDb();
await getOnboardingCounters(); await getOnboardingSnapshot();
expect(mockSelect).toHaveBeenCalledTimes(3); expect(db.select).toHaveBeenCalledTimes(4);
const sqls = mockSelect.mock.calls.map((c) => String(c[0])); const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!; const aggregate = sqls.find((s) => s.includes("import_sources"))!;
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'"); expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1"); expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
expect(aggregate).toContain("FROM budget_entries"); expect(aggregate).toContain("FROM budget_entries");
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/); expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
const prefKeys = mockSelect.mock.calls const prefKeys = db.select.mock.calls
.filter((c) => String(c[0]).includes("user_preferences")) .filter((c) => String(c[0]).includes("WHERE key = $1"))
.map((c) => (c[1] as unknown[])[0]); .map((c) => (c[1] as unknown[])[0]);
expect(prefKeys).toEqual( expect(prefKeys).toEqual(
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]), expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
); );
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
}); });
it("reports importFolderSet=false and hidden=false without preferences", async () => { it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
wireSelect({}); const { db } = useFakeDb({ transactions: 60 });
const snapshot = await getOnboardingCounters(); 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.importFolderSet).toBe(false);
expect(snapshot.hidden).toBe(false); expect(snapshot.hidden).toBe(false);
expect(snapshot.skipped).toEqual([]);
}); });
it("reports importFolderSet=true when a folder is stored", async () => { it("reports importFolderSet=true when a folder is stored", async () => {
wireSelect({ import_folder: "/home/max/releves" }); useFakeDb({ prefs: { import_folder: "/home/max/releves" } });
const snapshot = await getOnboardingCounters(); expect((await getOnboardingSnapshot()).importFolderSet).toBe(true);
expect(snapshot.importFolderSet).toBe(true);
}); });
it("treats an empty import_folder value as unset", async () => { it("treats an empty import_folder value as unset", async () => {
wireSelect({ import_folder: "" }); useFakeDb({ prefs: { import_folder: "" } });
const snapshot = await getOnboardingCounters(); expect((await getOnboardingSnapshot()).importFolderSet).toBe(false);
expect(snapshot.importFolderSet).toBe(false);
}); });
it('reports hidden=true only for the value "1"', async () => { it('reports hidden=true only for the value "1"', async () => {
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" }); useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
expect((await getOnboardingCounters()).hidden).toBe(true); expect((await getOnboardingSnapshot()).hidden).toBe(true);
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" }); useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" } });
expect((await getOnboardingCounters()).hidden).toBe(false); expect((await getOnboardingSnapshot()).hidden).toBe(false);
}); });
it("falls back to zero counters when the driver returns no row", async () => { it("falls back to zero counters when the driver returns no row", async () => {
wireSelect({}, null); useFakeDb({ counters: null });
const snapshot = await getOnboardingCounters(); expect((await getOnboardingSnapshot()).counters).toEqual({
expect(snapshot.counters).toEqual({
sources: 0, sources: 0,
imports: 0, imports: 0,
manualCategorized: 0, manualCategorized: 0,
@ -124,32 +193,214 @@ describe("getOnboardingCounters (#351)", () => {
transactions: 0, 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)", () => { describe("hideChecklist / showChecklist (#351)", () => {
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => { it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); const fake = useFakeDb();
await hideChecklist(); await hideChecklist();
expect(mockExecute).toHaveBeenCalledTimes(1); expect(fake.db.execute).toHaveBeenCalledTimes(1);
const [sql, params] = mockExecute.mock.calls[0]; const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("INSERT INTO user_preferences"); expect(String(sql)).toContain("INSERT INTO user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]); expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
expect(logInfo).toHaveBeenCalledTimes(1); expect(logInfo).toHaveBeenCalledTimes(1);
}); });
it("showChecklist deletes the preference and logs", async () => { it("showChecklist deletes the preference and logs", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); const fake = useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
await showChecklist(); await showChecklist();
expect(mockExecute).toHaveBeenCalledTimes(1); expect(fake.db.execute).toHaveBeenCalledTimes(1);
const [sql, params] = mockExecute.mock.calls[0]; const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("DELETE FROM user_preferences"); expect(String(sql)).toContain("DELETE FROM user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]); expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
expect(logInfo).toHaveBeenCalledTimes(1); expect(logInfo).toHaveBeenCalledTimes(1);
}); });
it("does not log when the write fails", async () => { 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"); await expect(hideChecklist()).rejects.toThrow("database is locked");
expect(logInfo).not.toHaveBeenCalled(); expect(logInfo).not.toHaveBeenCalled();
}); });
}); });
describe("skipStep / resumeStep (#396, D3)", () => {
it('skipStep writes "1" under onboarding_skipped_<key>, 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();
});
});

View file

@ -1,4 +1,4 @@
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import { import {
deletePreference, deletePreference,
getImportFolder, getImportFolder,
@ -6,26 +6,44 @@ import {
setPreference, setPreference,
} from "./userPreferenceService"; } from "./userPreferenceService";
import { logInfo } from "./logService"; 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: * `getOnboardingSnapshot` reads everything the path needs in one round of
* one aggregated query for the counters, the import folder preference and the * parallel reads, and writes nothing: the counters, the import folder, the
* "hidden" preference. No UI lives here; `computeChecklist` turns the result * "hidden" preference and the skipped steps. The writes are separate, and the
* into rows and `useOnboardingChecklist` holds the state. * 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"; 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_<key>`, value "1". */
export function skippedStepKey(key: ChecklistItemKey): string {
return `onboarding_skipped_${key}`;
}
export interface OnboardingSnapshot { export interface OnboardingSnapshot {
counters: OnboardingCounters; counters: OnboardingCounters;
/** True when an import folder has been chosen. */ /** True when an import folder has been chosen. */
importFolderSet: boolean; importFolderSet: boolean;
/** True when the checklist was dismissed by the user. */ /** True when the path is hidden (by the user, or by rule D9). */
hidden: boolean; hidden: boolean;
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
skipped: ChecklistItemKey[];
} }
interface CountersRow { interface CountersRow {
@ -45,6 +63,15 @@ const COUNTERS_SQL = `
(SELECT COUNT(*) FROM transactions) AS transactions (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<OnboardingCounters> { async function readCounters(): Promise<OnboardingCounters> {
const db = await getDb(); const db = await getDb();
const rows = await db.select<CountersRow[]>(COUNTERS_SQL); const rows = await db.select<CountersRow[]>(COUNTERS_SQL);
@ -58,28 +85,105 @@ async function readCounters(): Promise<OnboardingCounters> {
}; };
} }
/** Reads the counters, the import folder and the hidden flag in parallel. */ /** Skipped steps, read in one query and kept only for a valid key holding "1". */
export async function getOnboardingCounters(): Promise<OnboardingSnapshot> { async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
const [counters, importFolder, hiddenPref] = await Promise.all([ const db = await getDb();
const rows = await db.select<Array<{ key: string; value: string }>>(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<OnboardingSnapshot> {
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
readCounters(), readCounters(),
getImportFolder(), getImportFolder(),
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY), getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
readSkippedSteps(),
]); ]);
return { return {
counters, counters,
importFolderSet: importFolder !== null && importFolder !== "", importFolderSet: importFolder !== null && importFolder !== "",
hidden: hiddenPref === "1", 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<Array<{ value: string }>>(
"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<Array<{ transactions: number }>>(
"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<void> { export async function hideChecklist(): Promise<void> {
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"); await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
logInfo("Onboarding: checklist hidden"); 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<void> { export async function showChecklist(): Promise<void> {
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
logInfo("Onboarding: checklist shown"); logInfo("Onboarding: checklist shown");
} }
/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
export async function skipStep(key: ChecklistItemKey): Promise<void> {
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<void> {
await deletePreference(skippedStepKey(key));
logInfo(`Onboarding: step ${key} resumed`);
}

View file

@ -974,11 +974,30 @@ export type ChecklistItemKey =
| "manualCategory" | "manualCategory"
| "budget"; | "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. */ /** One row of the getting-started checklist. `to` is the route the row links to. */
export interface ChecklistItem { export interface ChecklistItem {
key: ChecklistItemKey; key: ChecklistItemKey;
/** The data says the step is done. */
done: boolean; done: boolean;
/** The user skipped the step (#396, D3). A row can be done and skipped. */
skipped: boolean;
/** True when the feature behind the row is not part of the current edition. */ /** True when the feature behind the row is not part of the current edition. */
locked: boolean; locked: boolean;
to: string; 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";

View file

@ -130,3 +130,59 @@ describe("computeChecklist — budget gating (#351)", () => {
expect(list.complete).toBe(true); 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));
});
});

View file

@ -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). * 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 * is only reachable from an open profile). The `budget` row is locked when the
* edition does not include Budget; a locked row is excluded from `total` and * edition does not include Budget; a locked row is excluded from `total` and
* from `complete`, and links to the Users settings (license card) instead. * from `complete`, and links to the Users settings (license card) instead.
*
* 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 { export interface ChecklistOptions {
@ -14,15 +18,17 @@ export interface ChecklistOptions {
importFolderSet: boolean; importFolderSet: boolean;
/** The Budget feature is part of the current edition. */ /** The Budget feature is part of the current edition. */
budgetAllowed: boolean; budgetAllowed: boolean;
/** Steps the user skipped (#396). None when omitted. */
skipped?: readonly ChecklistItemKey[];
} }
export interface Checklist { export interface Checklist {
items: ChecklistItem[]; items: ChecklistItem[];
/** Number of unlocked rows that are done. */ /** Number of unlocked rows that are done or skipped. */
done: number; done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */ /** Number of unlocked rows (6, or 5 when Budget is locked). */
total: number; total: number;
/** True when every unlocked row is done. */ /** True when every unlocked row is done or skipped. */
complete: boolean; complete: boolean;
} }
@ -31,29 +37,32 @@ export function computeChecklist(
options: ChecklistOptions options: ChecklistOptions
): Checklist { ): Checklist {
const budgetLocked = !options.budgetAllowed; 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[] = [ const items: ChecklistItem[] = [
// Always done: the checklist only exists inside an open profile. The link // 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. // is never rendered for a done row (#352); it points at profile management.
{ key: "profile", done: true, locked: false, to: "/settings/users" }, row("profile", true, "/settings/users"),
{ key: "importFolder", done: options.importFolderSet, locked: false, to: "/import" }, row("importFolder", options.importFolderSet, "/import"),
{ key: "source", done: counters.sources >= 1, locked: false, to: "/import" }, row("source", counters.sources >= 1, "/import"),
{ key: "import", done: counters.imports >= 1, locked: false, to: "/import" }, row("import", counters.imports >= 1, "/import"),
{ row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
key: "manualCategory", row(
done: counters.manualCategorized >= 1, "budget",
locked: false, counters.budgetEntries >= 1,
to: "/transactions", budgetLocked ? "/settings/users" : "/budget",
}, budgetLocked,
{ ),
key: "budget",
done: counters.budgetEntries >= 1,
locked: budgetLocked,
to: budgetLocked ? "/settings/users" : "/budget",
},
]; ];
const unlocked = items.filter((item) => !item.locked); 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; const total = unlocked.length;
return { items, done, total, complete: done === total }; return { items, done, total, complete: done === total };

View file

@ -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<string> = 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<BannerModeInput>) => 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 });
});
});

130
src/utils/onboardingPath.ts Normal file
View file

@ -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<string> = new Set<string>();
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/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<string>;
}
/**
* 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 };
}