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:
parent
4febed9eab
commit
dcf3b013dc
14 changed files with 2206 additions and 202 deletions
|
|
@ -1,12 +1,16 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { OnboardingProvider } from "../../contexts/OnboardingContext";
|
||||
|
||||
export default function AppShell() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
{/* The getting-started path, shared by every page (#396). */}
|
||||
<OnboardingProvider>
|
||||
<Outlet />
|
||||
</OnboardingProvider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
71
src/contexts/OnboardingContext.render.test.tsx
Normal file
71
src/contexts/OnboardingContext.render.test.tsx
Normal 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|");
|
||||
});
|
||||
});
|
||||
26
src/contexts/OnboardingContext.tsx
Normal file
26
src/contexts/OnboardingContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// useOnboardingChecklist — reducer unit tests (#351).
|
||||
// useOnboardingChecklist — reducer, loader and data-channel follower (#351, #396).
|
||||
//
|
||||
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
|
||||
// the exported reducer is driven directly.
|
||||
// the exported reducer, loader and follower are driven directly.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// The hook module imports the onboarding service (→ db → tauri plugins) and
|
||||
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
|
||||
|
|
@ -20,15 +20,24 @@ vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
|||
import {
|
||||
onboardingChecklistReducer,
|
||||
initialOnboardingChecklistState,
|
||||
createOnboardingLoader,
|
||||
deriveOnboardingView,
|
||||
followDataChanges,
|
||||
DATA_CHANGE_DELAY_MS,
|
||||
type OnboardingChecklistAction,
|
||||
type OnboardingChecklistState,
|
||||
type OnboardingLoaderDeps,
|
||||
} from "./useOnboardingChecklist";
|
||||
import { notifyDataChanged } from "../services/dataEvents";
|
||||
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||
import type { OnboardingSnapshot } from "../services/onboardingService";
|
||||
import type { OnboardingCounters } from "../shared/types";
|
||||
|
||||
const snapshot: OnboardingSnapshot = {
|
||||
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
|
||||
importFolderSet: true,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
const reduce = (
|
||||
|
|
@ -36,87 +45,688 @@ const reduce = (
|
|||
from: OnboardingChecklistState = initialOnboardingChecklistState,
|
||||
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
|
||||
|
||||
describe("onboardingChecklistReducer (#351)", () => {
|
||||
it("starts loading with no counters and not hidden", () => {
|
||||
/** A first successful load (seq 1). */
|
||||
const ready = (snap: OnboardingSnapshot = snapshot): OnboardingChecklistState =>
|
||||
reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_DONE", seq: 1, snapshot: snap },
|
||||
]);
|
||||
|
||||
describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
|
||||
it("starts loading with no counters, nothing hidden or skipped, and no card", () => {
|
||||
expect(initialOnboardingChecklistState).toEqual({
|
||||
status: "loading",
|
||||
counters: null,
|
||||
importFolderSet: false,
|
||||
hidden: false,
|
||||
error: null,
|
||||
skipped: [],
|
||||
loadSeq: 0,
|
||||
hiddenSetAt: 0,
|
||||
skippedSetAt: 0,
|
||||
cardPage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("LOAD_DONE stores the snapshot and sets ready", () => {
|
||||
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_DONE", snapshot }]);
|
||||
const s = ready({ ...snapshot, skipped: ["budget"] });
|
||||
expect(s.status).toBe("ready");
|
||||
expect(s.counters).toEqual(snapshot.counters);
|
||||
expect(s.importFolderSet).toBe(true);
|
||||
expect(s.hidden).toBe(false);
|
||||
expect(s.error).toBeNull();
|
||||
expect(s.skipped).toEqual(["budget"]);
|
||||
});
|
||||
|
||||
it("LOAD_DONE applies the persisted hidden flag", () => {
|
||||
const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
||||
expect(s.hidden).toBe(true);
|
||||
expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("LOAD_ERROR sets status=error with the message and keeps hidden", () => {
|
||||
const hiddenReady = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
||||
it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
|
||||
const s = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: true } },
|
||||
]);
|
||||
expect(s.status).toBe("loading");
|
||||
expect(s.counters).toBeNull();
|
||||
expect(s.hidden).toBe(false);
|
||||
|
||||
const answered = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], s);
|
||||
expect(answered.status).toBe("ready");
|
||||
expect(answered.counters).toEqual(snapshot.counters);
|
||||
});
|
||||
|
||||
it("a stale LOAD_DONE arriving after the newer one does not overwrite it", () => {
|
||||
const newer = { ...snapshot, counters: { ...snapshot.counters, imports: 4 } };
|
||||
const s = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: newer },
|
||||
{ type: "LOAD_DONE", seq: 1, snapshot },
|
||||
]);
|
||||
expect(s.counters?.imports).toBe(4);
|
||||
});
|
||||
|
||||
it("a refresh keeps ready and the counters while it runs", () => {
|
||||
const s = reduce([{ type: "LOAD_START", seq: 2 }], ready());
|
||||
expect(s.status).toBe("ready");
|
||||
expect(s.counters).toEqual(snapshot.counters);
|
||||
expect(s.importFolderSet).toBe(true);
|
||||
expect(s.loadSeq).toBe(2);
|
||||
});
|
||||
|
||||
it("a failed re-read after a first success keeps ready and the last state", () => {
|
||||
const hiddenReady = ready({ ...snapshot, hidden: true, skipped: ["source"] });
|
||||
const s = reduce(
|
||||
[{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }],
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_ERROR", seq: 2 },
|
||||
],
|
||||
hiddenReady,
|
||||
);
|
||||
expect(s.status).toBe("error");
|
||||
expect(s.error).toBe("database is locked");
|
||||
expect(s.hidden).toBe(true);
|
||||
// The last known counters survive a failed refresh.
|
||||
expect(s.status).toBe("ready");
|
||||
expect(s.counters).toEqual(snapshot.counters);
|
||||
expect(s.hidden).toBe(true);
|
||||
expect(s.skipped).toEqual(["source"]);
|
||||
});
|
||||
|
||||
it("LOAD_ERROR on a fresh state keeps hidden=false", () => {
|
||||
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
|
||||
it("a first load that fails sets status=error and keeps hidden=false", () => {
|
||||
const s = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_ERROR", seq: 1 },
|
||||
]);
|
||||
expect(s.status).toBe("error");
|
||||
expect(s.hidden).toBe(false);
|
||||
expect(s.counters).toBeNull();
|
||||
});
|
||||
|
||||
it("refresh after an error goes back to loading, then ready", () => {
|
||||
const errored = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
|
||||
const loading = reduce([{ type: "LOAD_START" }], errored);
|
||||
expect(loading.status).toBe("loading");
|
||||
expect(loading.error).toBeNull();
|
||||
const ready = reduce([{ type: "LOAD_DONE", snapshot }], loading);
|
||||
expect(ready.status).toBe("ready");
|
||||
expect(ready.error).toBeNull();
|
||||
expect(ready.counters).toEqual(snapshot.counters);
|
||||
});
|
||||
|
||||
it("HIDE sets hidden=true without touching the load state", () => {
|
||||
const ready = reduce([{ type: "LOAD_DONE", snapshot }]);
|
||||
const s = reduce([{ type: "HIDE" }], ready);
|
||||
expect(s.hidden).toBe(true);
|
||||
expect(s.status).toBe("ready");
|
||||
expect(s.counters).toEqual(snapshot.counters);
|
||||
});
|
||||
|
||||
it("SHOW sets hidden=false without touching the load state", () => {
|
||||
const hidden = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
||||
const s = reduce([{ type: "SHOW" }], hidden);
|
||||
expect(s.hidden).toBe(false);
|
||||
expect(s.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("HIDE while loading does not change the status", () => {
|
||||
const s = reduce([{ type: "LOAD_START" }, { type: "HIDE" }]);
|
||||
it("a stale LOAD_ERROR is ignored", () => {
|
||||
const s = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_ERROR", seq: 1 },
|
||||
]);
|
||||
expect(s.status).toBe("loading");
|
||||
expect(s.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("a retry after a failed first load goes back to loading, then ready", () => {
|
||||
const errored = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_ERROR", seq: 1 },
|
||||
]);
|
||||
const loading = reduce([{ type: "LOAD_START", seq: 2 }], errored);
|
||||
expect(loading.status).toBe("loading");
|
||||
const done = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], loading);
|
||||
expect(done.status).toBe("ready");
|
||||
expect(done.counters).toEqual(snapshot.counters);
|
||||
});
|
||||
|
||||
it("never mutates the previous state", () => {
|
||||
const before = reduce([{ type: "LOAD_DONE", snapshot }]);
|
||||
const before = ready();
|
||||
const frozen = Object.freeze({ ...before });
|
||||
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
|
||||
expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
|
||||
expect(frozen.hidden).toBe(false);
|
||||
expect(frozen.skipped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboardingChecklistReducer — local actions win over an older snapshot (#384, #396)", () => {
|
||||
it("HIDE during a load wins over that load's snapshot", () => {
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "HIDE" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(s.hidden).toBe(true);
|
||||
// The rest of the snapshot still lands.
|
||||
expect(s.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("SHOW during a load wins over that load's snapshot", () => {
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "SHOW" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
|
||||
],
|
||||
ready({ ...snapshot, hidden: true }),
|
||||
);
|
||||
expect(s.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("SKIP during a load wins over that load's snapshot", () => {
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "SKIP", key: "budget" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: [] } },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(s.skipped).toEqual(["budget"]);
|
||||
});
|
||||
|
||||
it("RESUME during a load wins over that load's snapshot", () => {
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "RESUME", key: "budget" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["budget"] } },
|
||||
],
|
||||
ready({ ...snapshot, skipped: ["budget"] }),
|
||||
);
|
||||
expect(s.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("HIDE during the very first load wins too", () => {
|
||||
const s = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "HIDE" },
|
||||
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: false } },
|
||||
]);
|
||||
expect(s.status).toBe("ready");
|
||||
expect(s.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("a load started after the action brings the stored value back", () => {
|
||||
// The user hid the path, then showed it again from Settings → Users:
|
||||
// a later load reads the preference, which is the truth again.
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "HIDE" },
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(s.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("HIDE does not shield the skipped steps, nor SKIP the hidden flag", () => {
|
||||
const afterHide = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "HIDE" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["source"] } },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(afterHide.skipped).toEqual(["source"]);
|
||||
|
||||
const afterSkip = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "SKIP", key: "budget" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(afterSkip.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("HIDE and SHOW do not change the load state", () => {
|
||||
const hidden = reduce([{ type: "HIDE" }], ready());
|
||||
expect(hidden.hidden).toBe(true);
|
||||
expect(hidden.status).toBe("ready");
|
||||
expect(hidden.counters).toEqual(snapshot.counters);
|
||||
const shown = reduce([{ type: "SHOW" }], hidden);
|
||||
expect(shown.hidden).toBe(false);
|
||||
expect(shown.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("HIDE while the first load runs keeps status loading", () => {
|
||||
const s = reduce([{ type: "LOAD_START", seq: 1 }, { type: "HIDE" }]);
|
||||
expect(s.status).toBe("loading");
|
||||
expect(s.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboardingChecklistReducer — skipped steps and the card latch (#396)", () => {
|
||||
it("SKIP keeps checklist order and never duplicates a step", () => {
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "SKIP", key: "budget" },
|
||||
{ type: "SKIP", key: "source" },
|
||||
{ type: "SKIP", key: "budget" },
|
||||
],
|
||||
ready(),
|
||||
);
|
||||
expect(s.skipped).toEqual(["source", "budget"]);
|
||||
});
|
||||
|
||||
it("RESUME removes only its step", () => {
|
||||
const s = reduce([{ type: "RESUME", key: "source" }], ready({ ...snapshot, skipped: ["source", "budget"] }));
|
||||
expect(s.skipped).toEqual(["budget"]);
|
||||
});
|
||||
|
||||
it("SKIP releases the card latch; RESUME, HIDE and a load do not touch it", () => {
|
||||
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
||||
expect(latched.cardPage).toBe("/import");
|
||||
expect(reduce([{ type: "SKIP", key: "importFolder" }], latched).cardPage).toBeNull();
|
||||
expect(reduce([{ type: "RESUME", key: "importFolder" }], latched).cardPage).toBe("/import");
|
||||
expect(
|
||||
reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot },
|
||||
],
|
||||
latched,
|
||||
).cardPage,
|
||||
).toBe("/import");
|
||||
});
|
||||
|
||||
it("SET_CARD_PAGE with the same page returns the same state object", () => {
|
||||
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
||||
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
|
||||
latched,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A promise whose resolution the test controls. */
|
||||
function deferred<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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,73 +1,139 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
getOnboardingCounters,
|
||||
getOnboardingSnapshot,
|
||||
hideChecklist,
|
||||
initializeGuidedPath,
|
||||
resumeStep,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
type OnboardingSnapshot,
|
||||
} from "../services/onboardingService";
|
||||
import { subscribeDataChanged } from "../services/dataEvents";
|
||||
import { logError } from "../services/logService";
|
||||
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
|
||||
import {
|
||||
bannerMode,
|
||||
latchCardPage,
|
||||
nextStep as findNextStep,
|
||||
type BannerMode,
|
||||
} from "../utils/onboardingPath";
|
||||
import { useEntitlement } from "./useEntitlement";
|
||||
import type { OnboardingCounters } from "../shared/types";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
type ChecklistItem,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
type OnboardingStatus,
|
||||
} from "../shared/types";
|
||||
|
||||
// State of the getting-started checklist (#351). The reducer and its initial
|
||||
// state are exported so the state machine is tested without renderHook (same
|
||||
// pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`).
|
||||
|
||||
export type OnboardingChecklistStatus = "loading" | "ready" | "error";
|
||||
// State of the getting-started path (#351, #396). One instance lives in
|
||||
// `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the
|
||||
// loader and the data-channel follower are exported so the state machine is
|
||||
// tested without renderHook (same pattern as `licenseReducer`,
|
||||
// `performSubmitKey`, `revocationCheckLauncher`).
|
||||
|
||||
export interface OnboardingChecklistState {
|
||||
status: OnboardingChecklistStatus;
|
||||
/** `error` only while no load ever succeeded; a failed re-read keeps `ready`. */
|
||||
status: OnboardingStatus;
|
||||
/** Null until the first successful load. */
|
||||
counters: OnboardingCounters | null;
|
||||
/** True when an import folder has been chosen (read with the counters). */
|
||||
importFolderSet: boolean;
|
||||
/** Dismissed by the user. Untouched by a load error. */
|
||||
/** Hidden by the user or by rule D9. Untouched by a load error. */
|
||||
hidden: boolean;
|
||||
/** Message of the last load error, null otherwise. */
|
||||
error: string | null;
|
||||
/** Skipped steps, in checklist order. */
|
||||
skipped: ChecklistItemKey[];
|
||||
/** Number of the last load started: the outcome of any other load is stale. */
|
||||
loadSeq: number;
|
||||
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
|
||||
hiddenSetAt: number;
|
||||
/** Same, for SKIP / RESUME and the skipped steps. */
|
||||
skippedSetAt: number;
|
||||
/** The page that carries a step card (`latchCardPage`), null otherwise. */
|
||||
cardPage: string | null;
|
||||
}
|
||||
|
||||
export type OnboardingChecklistAction =
|
||||
| { type: "LOAD_START" }
|
||||
| { type: "LOAD_DONE"; snapshot: OnboardingSnapshot }
|
||||
| { type: "LOAD_ERROR"; error: string }
|
||||
| { type: "LOAD_START"; seq: number }
|
||||
| { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot }
|
||||
| { type: "LOAD_ERROR"; seq: number }
|
||||
| { type: "HIDE" }
|
||||
| { type: "SHOW" };
|
||||
| { type: "SHOW" }
|
||||
| { type: "SKIP"; key: ChecklistItemKey }
|
||||
| { type: "RESUME"; key: ChecklistItemKey }
|
||||
| { type: "SET_CARD_PAGE"; cardPage: string | null };
|
||||
|
||||
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
||||
status: "loading",
|
||||
counters: null,
|
||||
importFolderSet: false,
|
||||
hidden: false,
|
||||
error: null,
|
||||
skipped: [],
|
||||
loadSeq: 0,
|
||||
hiddenSetAt: 0,
|
||||
skippedSetAt: 0,
|
||||
cardPage: null,
|
||||
};
|
||||
|
||||
/** `keys` plus `key`, kept in checklist order and without duplicates. */
|
||||
function withStep(keys: readonly ChecklistItemKey[], key: ChecklistItemKey): ChecklistItemKey[] {
|
||||
return CHECKLIST_ITEM_KEYS.filter((k) => k === key || keys.includes(k));
|
||||
}
|
||||
|
||||
export function onboardingChecklistReducer(
|
||||
state: OnboardingChecklistState,
|
||||
action: OnboardingChecklistAction,
|
||||
): OnboardingChecklistState {
|
||||
switch (action.type) {
|
||||
case "LOAD_START":
|
||||
// The previous counters stay so a refresh does not blank the list.
|
||||
return { ...state, status: "loading", error: null };
|
||||
case "LOAD_DONE":
|
||||
// A re-read keeps what is on screen, `ready` included: only a path that
|
||||
// never loaded shows `loading`.
|
||||
return {
|
||||
...state,
|
||||
loadSeq: action.seq,
|
||||
status: state.status === "ready" ? "ready" : "loading",
|
||||
};
|
||||
case "LOAD_DONE": {
|
||||
if (action.seq !== state.loadSeq) return state;
|
||||
const { snapshot } = action;
|
||||
return {
|
||||
...state,
|
||||
status: "ready",
|
||||
counters: action.snapshot.counters,
|
||||
importFolderSet: action.snapshot.importFolderSet,
|
||||
hidden: action.snapshot.hidden,
|
||||
error: null,
|
||||
counters: snapshot.counters,
|
||||
importFolderSet: snapshot.importFolderSet,
|
||||
// A local action dispatched after this load started wins over its
|
||||
// snapshot, read before the action's write (#384).
|
||||
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
|
||||
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
|
||||
};
|
||||
}
|
||||
case "LOAD_ERROR":
|
||||
// `hidden` is deliberately kept: a failed read must not resurface a
|
||||
// checklist the user dismissed.
|
||||
return { ...state, status: "error", error: action.error };
|
||||
if (action.seq !== state.loadSeq) return state;
|
||||
// After a first success the last state stays on screen; `hidden` is never
|
||||
// touched, so a failed read cannot resurface a path the user dismissed.
|
||||
return { ...state, status: state.status === "ready" ? "ready" : "error" };
|
||||
case "HIDE":
|
||||
return { ...state, hidden: true };
|
||||
return { ...state, hidden: true, hiddenSetAt: state.loadSeq };
|
||||
case "SHOW":
|
||||
return { ...state, hidden: false };
|
||||
return { ...state, hidden: false, hiddenSetAt: state.loadSeq };
|
||||
case "SKIP":
|
||||
return {
|
||||
...state,
|
||||
skipped: withStep(state.skipped, action.key),
|
||||
skippedSetAt: state.loadSeq,
|
||||
// "Skip" releases the card latch; the provider sets it again on the
|
||||
// next render if the new next step is on the same page.
|
||||
cardPage: null,
|
||||
};
|
||||
case "RESUME":
|
||||
return {
|
||||
...state,
|
||||
skipped: state.skipped.filter((key) => key !== action.key),
|
||||
skippedSetAt: state.loadSeq,
|
||||
};
|
||||
case "SET_CARD_PAGE":
|
||||
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,34 +141,191 @@ function errorMessage(e: unknown): string {
|
|||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export interface OnboardingLoaderDeps {
|
||||
initializeGuidedPath: () => Promise<{ hidden: boolean }>;
|
||||
getOnboardingSnapshot: () => Promise<OnboardingSnapshot>;
|
||||
dispatch: (action: OnboardingChecklistAction) => void;
|
||||
logError: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting-started checklist of the dashboard: counters read from the profile
|
||||
* database, dismissal preference, and the Budget entitlement that decides
|
||||
* whether the sixth row is locked.
|
||||
* The provider's load function (#396), kept outside React so it is tested
|
||||
* without a renderer:
|
||||
* - every load is numbered, and the reducer drops the outcome of any load but
|
||||
* the last one started (a stale answer never overwrites a newer one);
|
||||
* - the first load waits for rule D9 (`initializeGuidedPath`), run once per
|
||||
* loader: the promise is shared, so the double mount of StrictMode does not
|
||||
* run it twice. A failed initialization is logged and the load goes on, the
|
||||
* path showing as stored; the flag stays unset, so the next opening retries;
|
||||
* - a failed load is logged only when it starts a failure streak: repeated
|
||||
* re-reads must not push a real cause out of the last log lines that a
|
||||
* feedback report carries.
|
||||
*/
|
||||
export function useOnboardingChecklist() {
|
||||
export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promise<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(
|
||||
onboardingChecklistReducer,
|
||||
initialOnboardingChecklistState,
|
||||
);
|
||||
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const snapshot = await getOnboardingCounters();
|
||||
dispatch({ type: "LOAD_DONE", snapshot });
|
||||
} catch (e) {
|
||||
const error = errorMessage(e);
|
||||
logError("Onboarding: failed to load checklist counters", error);
|
||||
dispatch({ type: "LOAD_ERROR", error });
|
||||
}
|
||||
}, []);
|
||||
// One loader per provider instance: a profile switch remounts AppShell, so
|
||||
// the new profile gets its own numbering and its own D9 initialization.
|
||||
const [refresh] = useState(() =>
|
||||
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
|
||||
);
|
||||
|
||||
// Read-only local SELECTs: a StrictMode double run is harmless.
|
||||
// Read at mount, then again at every route change.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
}, [refresh, pathname]);
|
||||
|
||||
// Re-read after the writes that move a counter, once a burst is over.
|
||||
useEffect(() => followDataChanges(() => void refresh()), [refresh]);
|
||||
|
||||
const hide = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -122,28 +345,80 @@ export function useOnboardingChecklist() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(async (key: ChecklistItemKey) => {
|
||||
try {
|
||||
await skipStep(key);
|
||||
dispatch({ type: "SKIP", key });
|
||||
} catch (e) {
|
||||
logError("Onboarding: failed to skip a step", errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(async (key: ChecklistItemKey) => {
|
||||
try {
|
||||
await resumeStep(key);
|
||||
dispatch({ type: "RESUME", key });
|
||||
} catch (e) {
|
||||
logError("Onboarding: failed to resume a step", errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checklist: Checklist | null = useMemo(
|
||||
() =>
|
||||
state.counters
|
||||
? computeChecklist(state.counters, {
|
||||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed,
|
||||
skipped: state.skipped,
|
||||
})
|
||||
: null,
|
||||
[state.counters, state.importFolderSet, budgetAllowed],
|
||||
[state.counters, state.importFolderSet, state.skipped, budgetAllowed],
|
||||
);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
const { nextStep, cardPage, mode } = deriveOnboardingView({
|
||||
state,
|
||||
checklist,
|
||||
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
|
||||
counters: state.counters,
|
||||
hidden: state.hidden,
|
||||
/** Message of the last load error, null otherwise. */
|
||||
error: state.error,
|
||||
pathname,
|
||||
budgetReady,
|
||||
refresh,
|
||||
hide,
|
||||
show,
|
||||
};
|
||||
});
|
||||
// The card latch is derived during render, then stored: an effect would
|
||||
// leave one frame showing the banner on a page that carries a card. The
|
||||
// update is for this component's own reducer, which React applies before
|
||||
// committing; `latchCardPage` is idempotent, so it settles in one pass.
|
||||
if (cardPage !== state.cardPage) {
|
||||
dispatch({ type: "SET_CARD_PAGE", cardPage });
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: state.status,
|
||||
checklist,
|
||||
counters: state.counters,
|
||||
hidden: state.hidden,
|
||||
budgetReady,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
hide,
|
||||
show,
|
||||
skip,
|
||||
resume,
|
||||
refresh,
|
||||
}),
|
||||
[
|
||||
state.status,
|
||||
checklist,
|
||||
state.counters,
|
||||
state.hidden,
|
||||
budgetReady,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
hide,
|
||||
show,
|
||||
skip,
|
||||
resume,
|
||||
refresh,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
143
src/pages/DashboardPage.render.test.tsx
Normal file
143
src/pages/DashboardPage.render.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,13 +14,15 @@
|
|||
// profile without any transaction gets `DashboardEmptyState` instead of the
|
||||
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
||||
// transactions, the compact checklist sits between the title and the filter
|
||||
// panel until it is complete or dismissed. A load error falls back to the
|
||||
// v0.15.0 page without the list.
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
// panel until it is complete or dismissed. A first load that fails falls back
|
||||
// to the v0.15.0 page without the list; the provider logs it.
|
||||
//
|
||||
// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
|
||||
// by every page and re-read live, instead of a load of its own at mount.
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
|
||||
import { logWarn } from "../services/logService";
|
||||
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
|
|
@ -48,13 +50,7 @@ export default function DashboardPage() {
|
|||
setAccountIds,
|
||||
} = useDashboard();
|
||||
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
||||
const onboarding = useOnboardingChecklist();
|
||||
|
||||
useEffect(() => {
|
||||
if (onboarding.status === "error") {
|
||||
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
|
||||
}
|
||||
}, [onboarding.status, onboarding.error]);
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||
|
|
|
|||
|
|
@ -14,19 +14,21 @@ vi.mock("./logService", () => ({
|
|||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "./db";
|
||||
import { getDb, withTransaction } from "./db";
|
||||
import { logInfo } from "./logService";
|
||||
import { subscribeDataChanged } from "./dataEvents";
|
||||
import {
|
||||
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||
getOnboardingCounters,
|
||||
ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||
getOnboardingSnapshot,
|
||||
hideChecklist,
|
||||
initializeGuidedPath,
|
||||
resumeStep,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
skippedStepKey,
|
||||
} from "./onboardingService";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockExecute = vi.fn();
|
||||
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||
|
||||
const countersRow = {
|
||||
sources: 2,
|
||||
imports: 3,
|
||||
|
|
@ -35,31 +37,85 @@ const countersRow = {
|
|||
transactions: 60,
|
||||
};
|
||||
|
||||
interface FakeDbOptions {
|
||||
prefs?: Record<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
|
||||
* `user_preferences` lookup answered from `prefs`.
|
||||
* Just enough of `user_preferences` and of a transaction to watch what the
|
||||
* service writes: the preferences live in a map, BEGIN keeps a copy that
|
||||
* ROLLBACK restores, and every `execute` is logged.
|
||||
*/
|
||||
function wireSelect(prefs: Record<string, string>, row: unknown | null = countersRow) {
|
||||
mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||
if (sql.includes("user_preferences")) {
|
||||
function makeFakeDb(options: FakeDbOptions = {}) {
|
||||
let prefs: Record<string, string> = { ...options.prefs };
|
||||
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]);
|
||||
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
||||
}
|
||||
return row === null ? [] : [row];
|
||||
if (sql.includes("FROM import_sources")) {
|
||||
const row = options.counters === undefined ? countersRow : options.counters;
|
||||
return row === null ? [] : [row];
|
||||
}
|
||||
throw new Error(`unexpected SELECT: ${sql}`);
|
||||
});
|
||||
|
||||
const execute = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
statements.push(sql.trim().split(/\s+/).slice(0, 3).join(" "));
|
||||
if (options.failOn?.(sql, params)) throw new Error("database is locked");
|
||||
if (sql === "BEGIN") {
|
||||
beforeTransaction = { ...prefs };
|
||||
} else if (sql === "COMMIT") {
|
||||
beforeTransaction = null;
|
||||
} else if (sql === "ROLLBACK") {
|
||||
if (beforeTransaction) prefs = beforeTransaction;
|
||||
beforeTransaction = null;
|
||||
} else if (sql.includes("INSERT INTO user_preferences")) {
|
||||
prefs[String(params[0])] = String(params[1]);
|
||||
} else if (sql.includes("DELETE FROM user_preferences")) {
|
||||
delete prefs[String(params[0])];
|
||||
} else {
|
||||
throw new Error(`unexpected execute: ${sql}`);
|
||||
}
|
||||
return { rowsAffected: 1 };
|
||||
});
|
||||
|
||||
return { db: { select, execute }, statements, prefs: () => prefs };
|
||||
}
|
||||
|
||||
function useFakeDb(options: FakeDbOptions = {}) {
|
||||
const fake = makeFakeDb(options);
|
||||
vi.mocked(getDb).mockResolvedValue(fake.db as never);
|
||||
return fake;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||
mockSelect.mockReset();
|
||||
mockExecute.mockReset();
|
||||
vi.mocked(getDb).mockReset();
|
||||
vi.mocked(withTransaction).mockClear();
|
||||
vi.mocked(logInfo).mockReset();
|
||||
});
|
||||
|
||||
describe("getOnboardingCounters (#351)", () => {
|
||||
describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||
it("maps the aggregated row to camelCase counters", async () => {
|
||||
wireSelect({});
|
||||
const snapshot = await getOnboardingCounters();
|
||||
useFakeDb();
|
||||
const snapshot = await getOnboardingSnapshot();
|
||||
expect(snapshot.counters).toEqual({
|
||||
sources: 2,
|
||||
imports: 3,
|
||||
|
|
@ -69,54 +125,67 @@ describe("getOnboardingCounters (#351)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("runs the five sub-counts in one query plus the two preference reads", async () => {
|
||||
wireSelect({});
|
||||
await getOnboardingCounters();
|
||||
expect(mockSelect).toHaveBeenCalledTimes(3);
|
||||
const sqls = mockSelect.mock.calls.map((c) => String(c[0]));
|
||||
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
|
||||
const { db } = useFakeDb();
|
||||
await getOnboardingSnapshot();
|
||||
expect(db.select).toHaveBeenCalledTimes(4);
|
||||
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
||||
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
||||
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
|
||||
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
||||
expect(aggregate).toContain("FROM budget_entries");
|
||||
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
|
||||
const prefKeys = mockSelect.mock.calls
|
||||
.filter((c) => String(c[0]).includes("user_preferences"))
|
||||
const prefKeys = db.select.mock.calls
|
||||
.filter((c) => String(c[0]).includes("WHERE key = $1"))
|
||||
.map((c) => (c[1] as unknown[])[0]);
|
||||
expect(prefKeys).toEqual(
|
||||
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
|
||||
);
|
||||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports importFolderSet=false and hidden=false without preferences", async () => {
|
||||
wireSelect({});
|
||||
const snapshot = await getOnboardingCounters();
|
||||
it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
|
||||
const { db } = useFakeDb({ transactions: 60 });
|
||||
const unsubscribe = subscribeDataChanged(() => {
|
||||
throw new Error("a snapshot must not announce anything");
|
||||
});
|
||||
try {
|
||||
await getOnboardingSnapshot();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
expect(withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports importFolderSet=false, hidden=false and no skipped step without preferences", async () => {
|
||||
useFakeDb();
|
||||
const snapshot = await getOnboardingSnapshot();
|
||||
expect(snapshot.importFolderSet).toBe(false);
|
||||
expect(snapshot.hidden).toBe(false);
|
||||
expect(snapshot.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports importFolderSet=true when a folder is stored", async () => {
|
||||
wireSelect({ import_folder: "/home/max/releves" });
|
||||
const snapshot = await getOnboardingCounters();
|
||||
expect(snapshot.importFolderSet).toBe(true);
|
||||
useFakeDb({ prefs: { import_folder: "/home/max/releves" } });
|
||||
expect((await getOnboardingSnapshot()).importFolderSet).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty import_folder value as unset", async () => {
|
||||
wireSelect({ import_folder: "" });
|
||||
const snapshot = await getOnboardingCounters();
|
||||
expect(snapshot.importFolderSet).toBe(false);
|
||||
useFakeDb({ prefs: { import_folder: "" } });
|
||||
expect((await getOnboardingSnapshot()).importFolderSet).toBe(false);
|
||||
});
|
||||
|
||||
it('reports hidden=true only for the value "1"', async () => {
|
||||
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" });
|
||||
expect((await getOnboardingCounters()).hidden).toBe(true);
|
||||
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" });
|
||||
expect((await getOnboardingCounters()).hidden).toBe(false);
|
||||
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
|
||||
expect((await getOnboardingSnapshot()).hidden).toBe(true);
|
||||
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" } });
|
||||
expect((await getOnboardingSnapshot()).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to zero counters when the driver returns no row", async () => {
|
||||
wireSelect({}, null);
|
||||
const snapshot = await getOnboardingCounters();
|
||||
expect(snapshot.counters).toEqual({
|
||||
useFakeDb({ counters: null });
|
||||
expect((await getOnboardingSnapshot()).counters).toEqual({
|
||||
sources: 0,
|
||||
imports: 0,
|
||||
manualCategorized: 0,
|
||||
|
|
@ -124,32 +193,214 @@ describe("getOnboardingCounters (#351)", () => {
|
|||
transactions: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the skipped steps in checklist order", async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
[skippedStepKey("budget")]: "1",
|
||||
[skippedStepKey("source")]: "1",
|
||||
},
|
||||
});
|
||||
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]);
|
||||
});
|
||||
|
||||
it("keeps only the six keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
onboarding_skipped_dragons: "1",
|
||||
onboarding_skipped_: "1",
|
||||
onboarding_skipped_import_extra: "1",
|
||||
onboardingXskippedYimport: "1",
|
||||
[skippedStepKey("import")]: "1",
|
||||
},
|
||||
});
|
||||
expect((await getOnboardingSnapshot()).skipped).toEqual(["import"]);
|
||||
});
|
||||
|
||||
it('counts a skipped step only for the value "1"', async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
[skippedStepKey("source")]: "0",
|
||||
[skippedStepKey("import")]: "true",
|
||||
[skippedStepKey("budget")]: "",
|
||||
[skippedStepKey("manualCategory")]: "1",
|
||||
},
|
||||
});
|
||||
expect((await getOnboardingSnapshot()).skipped).toEqual(["manualCategory"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||
it("without the flag and with transactions: hides, then flags, inside one transaction", async () => {
|
||||
const fake = useFakeDb({ transactions: 3 });
|
||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: true });
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
const writes = fake.db.execute.mock.calls.map(([sql, params]) => [
|
||||
String(sql).trim().split(/\s+/)[0],
|
||||
(params as unknown[] | undefined)?.[0],
|
||||
]);
|
||||
expect(writes).toEqual([
|
||||
["BEGIN", undefined],
|
||||
["INSERT", ONBOARDING_CHECKLIST_HIDDEN_KEY],
|
||||
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
||||
["COMMIT", undefined],
|
||||
]);
|
||||
// The flag and the transaction count are read after BEGIN.
|
||||
const firstSelect = fake.db.select.mock.invocationCallOrder[0];
|
||||
expect(firstSelect).toBeGreaterThan(fake.db.execute.mock.invocationCallOrder[0]);
|
||||
expect(fake.prefs()).toEqual({
|
||||
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
||||
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
||||
});
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("without the flag and without transactions: flags only, the path stays visible", async () => {
|
||||
const fake = useFakeDb({ transactions: 0 });
|
||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||
expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "COMMIT"]);
|
||||
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with the flag already set: no write at all, even with transactions", async () => {
|
||||
const fake = useFakeDb({
|
||||
transactions: 3,
|
||||
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
||||
});
|
||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||
expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
|
||||
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a profile shown again after D9 is not hidden a second time", async () => {
|
||||
// D9 hid it, the user showed it again from Settings → Users.
|
||||
const fake = useFakeDb({ transactions: 3 });
|
||||
await initializeGuidedPath();
|
||||
await showChecklist();
|
||||
await initializeGuidedPath();
|
||||
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a failed hiding write rolls back and leaves the flag unset", async () => {
|
||||
const fake = useFakeDb({
|
||||
transactions: 3,
|
||||
failOn: (sql, params) =>
|
||||
sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||
});
|
||||
await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
|
||||
expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "ROLLBACK"]);
|
||||
expect(fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY]).toBeUndefined();
|
||||
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a failed flag write rolls the hiding back too", async () => {
|
||||
const fake = useFakeDb({
|
||||
transactions: 3,
|
||||
failOn: (sql, params) =>
|
||||
sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||
});
|
||||
await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
|
||||
expect(fake.statements[fake.statements.length - 1]).toBe("ROLLBACK");
|
||||
expect(fake.prefs()).toEqual({});
|
||||
});
|
||||
|
||||
it("the next snapshot comes back hidden", async () => {
|
||||
useFakeDb({ transactions: 3, counters: { ...countersRow, transactions: 3 } });
|
||||
expect((await getOnboardingSnapshot()).hidden).toBe(false);
|
||||
await initializeGuidedPath();
|
||||
expect((await getOnboardingSnapshot()).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("does not announce anything on the data channel", async () => {
|
||||
useFakeDb({ transactions: 3 });
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeDataChanged(listener);
|
||||
try {
|
||||
await initializeGuidedPath();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideChecklist / showChecklist (#351)", () => {
|
||||
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
|
||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||
const fake = useFakeDb();
|
||||
await hideChecklist();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = mockExecute.mock.calls[0];
|
||||
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = fake.db.execute.mock.calls[0];
|
||||
expect(String(sql)).toContain("INSERT INTO user_preferences");
|
||||
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("showChecklist deletes the preference and logs", async () => {
|
||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||
const fake = useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
|
||||
await showChecklist();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = mockExecute.mock.calls[0];
|
||||
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = fake.db.execute.mock.calls[0];
|
||||
expect(String(sql)).toContain("DELETE FROM user_preferences");
|
||||
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not log when the write fails", async () => {
|
||||
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||
useFakeDb({ failOn: () => true });
|
||||
await expect(hideChecklist()).rejects.toThrow("database is locked");
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipStep / resumeStep (#396, D3)", () => {
|
||||
it('skipStep writes "1" under onboarding_skipped_<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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getDb } from "./db";
|
||||
import { getDb, withTransaction } from "./db";
|
||||
import {
|
||||
deletePreference,
|
||||
getImportFolder,
|
||||
|
|
@ -6,26 +6,44 @@ import {
|
|||
setPreference,
|
||||
} from "./userPreferenceService";
|
||||
import { logInfo } from "./logService";
|
||||
import type { OnboardingCounters } from "../shared/types";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
/**
|
||||
* Getting-started checklist data (#351).
|
||||
* Getting-started path data (#351, #396).
|
||||
*
|
||||
* Everything the checklist needs is read in a single round of parallel reads:
|
||||
* one aggregated query for the counters, the import folder preference and the
|
||||
* "hidden" preference. No UI lives here; `computeChecklist` turns the result
|
||||
* into rows and `useOnboardingChecklist` holds the state.
|
||||
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
||||
* parallel reads, and writes nothing: the counters, the import folder, the
|
||||
* "hidden" preference and the skipped steps. The writes are separate, and the
|
||||
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
||||
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
|
||||
* announced on the data channel: the provider updates its own state. No UI
|
||||
* lives here; `computeChecklist` turns the snapshot into rows and
|
||||
* `useOnboardingChecklist` holds the state.
|
||||
*/
|
||||
|
||||
/** `user_preferences` key set to "1" when the user dismissed the checklist. */
|
||||
/** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */
|
||||
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
|
||||
|
||||
/** `user_preferences` key set to "1" once rule D9 ran for the profile. */
|
||||
export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized";
|
||||
|
||||
/** `user_preferences` key of a skipped step (D3): `onboarding_skipped_<key>`, value "1". */
|
||||
export function skippedStepKey(key: ChecklistItemKey): string {
|
||||
return `onboarding_skipped_${key}`;
|
||||
}
|
||||
|
||||
export interface OnboardingSnapshot {
|
||||
counters: OnboardingCounters;
|
||||
/** True when an import folder has been chosen. */
|
||||
importFolderSet: boolean;
|
||||
/** True when the checklist was dismissed by the user. */
|
||||
/** True when the path is hidden (by the user, or by rule D9). */
|
||||
hidden: boolean;
|
||||
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
||||
skipped: ChecklistItemKey[];
|
||||
}
|
||||
|
||||
interface CountersRow {
|
||||
|
|
@ -45,6 +63,15 @@ const COUNTERS_SQL = `
|
|||
(SELECT COUNT(*) FROM transactions) AS transactions
|
||||
`;
|
||||
|
||||
// `_` is a LIKE wildcard: the pattern may match more than the prefix, and the
|
||||
// exact-key filter of `readSkippedSteps` is what decides.
|
||||
const SKIPPED_SQL =
|
||||
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
||||
|
||||
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
||||
|
||||
async function readCounters(): Promise<OnboardingCounters> {
|
||||
const db = await getDb();
|
||||
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. */
|
||||
export async function getOnboardingCounters(): Promise<OnboardingSnapshot> {
|
||||
const [counters, importFolder, hiddenPref] = await Promise.all([
|
||||
/** Skipped steps, read in one query and kept only for a valid key holding "1". */
|
||||
async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
||||
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(),
|
||||
getImportFolder(),
|
||||
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
||||
readSkippedSteps(),
|
||||
]);
|
||||
return {
|
||||
counters,
|
||||
importFolderSet: importFolder !== null && importFolder !== "",
|
||||
hidden: hiddenPref === "1",
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dismisses the checklist until `showChecklist()` is called. */
|
||||
/**
|
||||
* Rule D9, once per profile: at the first opening under this version, a
|
||||
* profile that already has transactions gets the path hidden (same preference
|
||||
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
||||
* it. Runs as one transaction under the DB lock, flag re-read inside it, so two
|
||||
* concurrent first loads cannot apply it twice (CWE-362): the hidden preference
|
||||
* is written first, then the flag, then COMMIT; any failure rolls both back and
|
||||
* leaves the flag unset, so the next opening tries again.
|
||||
*
|
||||
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
||||
* writes to the in-app log. Not announced on the data channel.
|
||||
*/
|
||||
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
||||
const hidden = await withTransaction(async (db) => {
|
||||
await db.execute("BEGIN");
|
||||
try {
|
||||
const flag = await db.select<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> {
|
||||
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
|
||||
logInfo("Onboarding: checklist hidden");
|
||||
}
|
||||
|
||||
/** Shows the checklist again (removes the dismissal preference). */
|
||||
/** Shows the path again (removes the hidden preference). */
|
||||
export async function showChecklist(): Promise<void> {
|
||||
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
||||
logInfo("Onboarding: checklist shown");
|
||||
}
|
||||
|
||||
/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
|
||||
export async function skipStep(key: ChecklistItemKey): Promise<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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -974,11 +974,30 @@ export type ChecklistItemKey =
|
|||
| "manualCategory"
|
||||
| "budget";
|
||||
|
||||
/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
|
||||
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
];
|
||||
|
||||
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
||||
export interface ChecklistItem {
|
||||
key: ChecklistItemKey;
|
||||
/** The data says the step is done. */
|
||||
done: boolean;
|
||||
/** The user skipped the step (#396, D3). A row can be done and skipped. */
|
||||
skipped: boolean;
|
||||
/** True when the feature behind the row is not part of the current edition. */
|
||||
locked: boolean;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load status of the getting-started path (#396). `error` only before a first
|
||||
* successful load: a later failed re-read keeps `ready` and the last state.
|
||||
*/
|
||||
export type OnboardingStatus = "loading" | "ready" | "error";
|
||||
|
|
|
|||
|
|
@ -130,3 +130,59 @@ describe("computeChecklist — budget gating (#351)", () => {
|
|||
expect(list.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeChecklist — skipped steps (#396, D3)", () => {
|
||||
const fiveDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||
|
||||
it("no row is skipped when the option is omitted or empty", () => {
|
||||
expect(computeChecklist(zero, open).items.every((i) => !i.skipped)).toBe(true);
|
||||
expect(computeChecklist(zero, { ...open, skipped: [] }).items.every((i) => !i.skipped)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("a skipped row is marked skipped, not done, and counts in the counter", () => {
|
||||
const list = computeChecklist(zero, { ...open, skipped: ["importFolder"] });
|
||||
const row = list.items.find((i) => i.key === "importFolder")!;
|
||||
expect(row.skipped).toBe(true);
|
||||
expect(row.done).toBe(false);
|
||||
expect(list.done).toBe(2); // profile + the skipped row
|
||||
expect(list.total).toBe(6);
|
||||
});
|
||||
|
||||
it("complete with done and skipped rows", () => {
|
||||
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(6);
|
||||
expect(list.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("a locked row stays out of the total, even skipped", () => {
|
||||
const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
|
||||
const budget = list.items.find((i) => i.key === "budget")!;
|
||||
expect(budget.locked).toBe(true);
|
||||
expect(list.total).toBe(5);
|
||||
expect(list.done).toBe(1);
|
||||
expect(list.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("a row both done and skipped counts once", () => {
|
||||
const list = computeChecklist(fiveDone, {
|
||||
...open,
|
||||
importFolderSet: true,
|
||||
skipped: ["source", "import", "budget"],
|
||||
});
|
||||
const source = list.items.find((i) => i.key === "source")!;
|
||||
expect(source.done).toBe(true);
|
||||
expect(source.skipped).toBe(true);
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(6);
|
||||
});
|
||||
|
||||
it("skipping does not change the rows' links", () => {
|
||||
const to = computeChecklist(zero, { ...open, skipped: ["manualCategory", "budget"] }).items.map(
|
||||
(i) => i.to,
|
||||
);
|
||||
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ChecklistItem, OnboardingCounters } from "../shared/types";
|
||||
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Pure computation of the getting-started checklist (#351).
|
||||
|
|
@ -7,6 +7,10 @@ import type { ChecklistItem, OnboardingCounters } from "../shared/types";
|
|||
* is only reachable from an open profile). The `budget` row is locked when the
|
||||
* edition does not include Budget; a locked row is excluded from `total` and
|
||||
* from `complete`, and links to the Users settings (license card) instead.
|
||||
*
|
||||
* A row can also be skipped by the user (#396, D3). A skipped row counts like a
|
||||
* done one, once even when it is both, unless it is locked: a locked row stays
|
||||
* out of the total whatever its state.
|
||||
*/
|
||||
|
||||
export interface ChecklistOptions {
|
||||
|
|
@ -14,15 +18,17 @@ export interface ChecklistOptions {
|
|||
importFolderSet: boolean;
|
||||
/** The Budget feature is part of the current edition. */
|
||||
budgetAllowed: boolean;
|
||||
/** Steps the user skipped (#396). None when omitted. */
|
||||
skipped?: readonly ChecklistItemKey[];
|
||||
}
|
||||
|
||||
export interface Checklist {
|
||||
items: ChecklistItem[];
|
||||
/** Number of unlocked rows that are done. */
|
||||
/** Number of unlocked rows that are done or skipped. */
|
||||
done: number;
|
||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||
total: number;
|
||||
/** True when every unlocked row is done. */
|
||||
/** True when every unlocked row is done or skipped. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -31,29 +37,32 @@ export function computeChecklist(
|
|||
options: ChecklistOptions
|
||||
): Checklist {
|
||||
const budgetLocked = !options.budgetAllowed;
|
||||
const skipped = new Set(options.skipped ?? []);
|
||||
const row = (
|
||||
key: ChecklistItemKey,
|
||||
done: boolean,
|
||||
to: string,
|
||||
locked = false,
|
||||
): ChecklistItem => ({ key, done, skipped: skipped.has(key), locked, to });
|
||||
|
||||
const items: ChecklistItem[] = [
|
||||
// Always done: the checklist only exists inside an open profile. The link
|
||||
// is never rendered for a done row (#352); it points at profile management.
|
||||
{ key: "profile", done: true, locked: false, to: "/settings/users" },
|
||||
{ key: "importFolder", done: options.importFolderSet, locked: false, to: "/import" },
|
||||
{ key: "source", done: counters.sources >= 1, locked: false, to: "/import" },
|
||||
{ key: "import", done: counters.imports >= 1, locked: false, to: "/import" },
|
||||
{
|
||||
key: "manualCategory",
|
||||
done: counters.manualCategorized >= 1,
|
||||
locked: false,
|
||||
to: "/transactions",
|
||||
},
|
||||
{
|
||||
key: "budget",
|
||||
done: counters.budgetEntries >= 1,
|
||||
locked: budgetLocked,
|
||||
to: budgetLocked ? "/settings/users" : "/budget",
|
||||
},
|
||||
row("profile", true, "/settings/users"),
|
||||
row("importFolder", options.importFolderSet, "/import"),
|
||||
row("source", counters.sources >= 1, "/import"),
|
||||
row("import", counters.imports >= 1, "/import"),
|
||||
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
|
||||
row(
|
||||
"budget",
|
||||
counters.budgetEntries >= 1,
|
||||
budgetLocked ? "/settings/users" : "/budget",
|
||||
budgetLocked,
|
||||
),
|
||||
];
|
||||
|
||||
const unlocked = items.filter((item) => !item.locked);
|
||||
const done = unlocked.filter((item) => item.done).length;
|
||||
const done = unlocked.filter((item) => item.done || item.skipped).length;
|
||||
const total = unlocked.length;
|
||||
|
||||
return { items, done, total, complete: done === total };
|
||||
|
|
|
|||
310
src/utils/onboardingPath.test.ts
Normal file
310
src/utils/onboardingPath.test.ts
Normal 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
130
src/utils/onboardingPath.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue