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