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