From f77ce94505d9aef254f3251c86777de5ddc2ce4f Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 3 Oct 2026 19:22:18 -0400 Subject: [PATCH] feat(onboarding): add transaction count, onboarding counters and checklist reducer (#351) Lay the data foundation of the getting-started checklist, without UI: - transactionService.getTransactionCount(): unfiltered COUNT(*) of transactions - onboardingService: getOnboardingCounters() reads the five aggregated counts, the import folder and the onboarding_checklist_hidden preference in one Promise.all; hideChecklist()/showChecklist() persist the preference and log - utils/onboardingChecklist.computeChecklist(): pure computation of the six rows (profile, importFolder, source, import, manualCategory, budget), with the budget row locked and excluded from the total when the edition has no Budget - hooks/useOnboardingChecklist: exported reducer + initial state, actions refresh/hide/show, composed with useEntitlement("budget"); a load error sets status "error" and keeps the hidden flag - types OnboardingCounters, ChecklistItem, ChecklistItemKey Tests cover every row in isolation, the budget gating (total 5 vs 6, complete at 5/5 locked and 6/6 open), the aggregated query and preference reads, and every reducer action. No DB migration, no CHANGELOG entry (centralised in #357). Resolves #351 Co-Authored-By: Claude Fable 5.1 --- src/hooks/useOnboardingChecklist.test.ts | 122 ++++++++++++++++++ src/hooks/useOnboardingChecklist.ts | 144 +++++++++++++++++++++ src/services/onboardingService.test.ts | 155 +++++++++++++++++++++++ src/services/onboardingService.ts | 85 +++++++++++++ src/services/transactionService.test.ts | 43 +++++++ src/services/transactionService.ts | 13 ++ src/shared/types/index.ts | 30 +++++ src/utils/onboardingChecklist.test.ts | 132 +++++++++++++++++++ src/utils/onboardingChecklist.ts | 60 +++++++++ 9 files changed, 784 insertions(+) create mode 100644 src/hooks/useOnboardingChecklist.test.ts create mode 100644 src/hooks/useOnboardingChecklist.ts create mode 100644 src/services/onboardingService.test.ts create mode 100644 src/services/onboardingService.ts create mode 100644 src/services/transactionService.test.ts create mode 100644 src/utils/onboardingChecklist.test.ts create mode 100644 src/utils/onboardingChecklist.ts diff --git a/src/hooks/useOnboardingChecklist.test.ts b/src/hooks/useOnboardingChecklist.test.ts new file mode 100644 index 0000000..db5fd23 --- /dev/null +++ b/src/hooks/useOnboardingChecklist.test.ts @@ -0,0 +1,122 @@ +// useOnboardingChecklist — reducer unit tests (#351). +// +// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts): +// the exported reducer is driven directly. + +import { describe, it, expect, vi } from "vitest"; + +// The hook module imports the onboarding service (→ db → tauri plugins) and +// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both +// so importing the module never touches a real connection. +vi.mock("../services/db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); +vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() })); + +import { + onboardingChecklistReducer, + initialOnboardingChecklistState, + type OnboardingChecklistAction, + type OnboardingChecklistState, +} from "./useOnboardingChecklist"; +import type { OnboardingSnapshot } from "../services/onboardingService"; + +const snapshot: OnboardingSnapshot = { + counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, + importFolderSet: true, + hidden: false, +}; + +const reduce = ( + actions: OnboardingChecklistAction[], + from: OnboardingChecklistState = initialOnboardingChecklistState, +): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from); + +describe("onboardingChecklistReducer (#351)", () => { + it("starts loading with no counters and not hidden", () => { + expect(initialOnboardingChecklistState).toEqual({ + status: "loading", + counters: null, + importFolderSet: false, + hidden: false, + error: null, + }); + }); + + it("LOAD_DONE stores the snapshot and sets ready", () => { + const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_DONE", snapshot }]); + 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(); + }); + + it("LOAD_DONE applies the persisted hidden flag", () => { + const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]); + expect(s.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 } }]); + const s = reduce( + [{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }], + 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.counters).toEqual(snapshot.counters); + }); + + it("LOAD_ERROR on a fresh state keeps hidden=false", () => { + const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]); + 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" }]); + expect(s.status).toBe("loading"); + expect(s.hidden).toBe(true); + }); + + it("never mutates the previous state", () => { + const before = reduce([{ type: "LOAD_DONE", snapshot }]); + const frozen = Object.freeze({ ...before }); + expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow(); + expect(frozen.hidden).toBe(false); + }); +}); diff --git a/src/hooks/useOnboardingChecklist.ts b/src/hooks/useOnboardingChecklist.ts new file mode 100644 index 0000000..b2f1eed --- /dev/null +++ b/src/hooks/useOnboardingChecklist.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useMemo, useReducer } from "react"; +import { + getOnboardingCounters, + hideChecklist, + showChecklist, + type OnboardingSnapshot, +} from "../services/onboardingService"; +import { logError } from "../services/logService"; +import { computeChecklist, type Checklist } from "../utils/onboardingChecklist"; +import { useEntitlement } from "./useEntitlement"; +import type { OnboardingCounters } 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"; + +export interface OnboardingChecklistState { + status: OnboardingChecklistStatus; + /** 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: boolean; + /** Message of the last load error, null otherwise. */ + error: string | null; +} + +export type OnboardingChecklistAction = + | { type: "LOAD_START" } + | { type: "LOAD_DONE"; snapshot: OnboardingSnapshot } + | { type: "LOAD_ERROR"; error: string } + | { type: "HIDE" } + | { type: "SHOW" }; + +export const initialOnboardingChecklistState: OnboardingChecklistState = { + status: "loading", + counters: null, + importFolderSet: false, + hidden: false, + error: null, +}; + +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": + return { + ...state, + status: "ready", + counters: action.snapshot.counters, + importFolderSet: action.snapshot.importFolderSet, + hidden: action.snapshot.hidden, + error: null, + }; + 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 }; + case "HIDE": + return { ...state, hidden: true }; + case "SHOW": + return { ...state, hidden: false }; + } +} + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** + * 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. + */ +export function useOnboardingChecklist() { + const [state, dispatch] = useReducer( + onboardingChecklistReducer, + initialOnboardingChecklistState, + ); + const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget"); + + const refresh = useCallback(async () => { + dispatch({ type: "LOAD_START" }); + try { + const snapshot = await getOnboardingCounters(); + dispatch({ type: "LOAD_DONE", snapshot }); + } catch (e) { + logError("Onboarding: failed to load checklist counters", errorMessage(e)); + dispatch({ type: "LOAD_ERROR", error: errorMessage(e) }); + } + }, []); + + // Read-only local SELECTs: a StrictMode double run is harmless. + useEffect(() => { + void refresh(); + }, [refresh]); + + const hide = useCallback(async () => { + try { + await hideChecklist(); + dispatch({ type: "HIDE" }); + } catch (e) { + logError("Onboarding: failed to hide checklist", errorMessage(e)); + } + }, []); + + const show = useCallback(async () => { + try { + await showChecklist(); + dispatch({ type: "SHOW" }); + } catch (e) { + logError("Onboarding: failed to show checklist", errorMessage(e)); + } + }, []); + + const checklist: Checklist | null = useMemo( + () => + state.counters + ? computeChecklist(state.counters, { + importFolderSet: state.importFolderSet, + budgetAllowed, + }) + : null, + [state.counters, state.importFolderSet, budgetAllowed], + ); + + return { + status: state.status, + checklist, + hidden: state.hidden, + budgetReady, + refresh, + hide, + show, + }; +} diff --git a/src/services/onboardingService.test.ts b/src/services/onboardingService.test.ts new file mode 100644 index 0000000..fb58ebb --- /dev/null +++ b/src/services/onboardingService.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); + +vi.mock("./logService", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +import { getDb } from "./db"; +import { logInfo } from "./logService"; +import { + ONBOARDING_CHECKLIST_HIDDEN_KEY, + getOnboardingCounters, + hideChecklist, + showChecklist, +} from "./onboardingService"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +const countersRow = { + sources: 2, + imports: 3, + manual_categorized: 4, + budget_entries: 5, + transactions: 60, +}; + +/** + * Routes each SELECT by its SQL: the aggregated counters query, or a + * `user_preferences` lookup answered from `prefs`. + */ +function wireSelect(prefs: Record, row: unknown | null = countersRow) { + mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => { + if (sql.includes("user_preferences")) { + const key = String(params?.[0]); + return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : []; + } + return row === null ? [] : [row]; + }); +} + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); + vi.mocked(logInfo).mockReset(); +}); + +describe("getOnboardingCounters (#351)", () => { + it("maps the aggregated row to camelCase counters", async () => { + wireSelect({}); + const snapshot = await getOnboardingCounters(); + expect(snapshot.counters).toEqual({ + sources: 2, + imports: 3, + manualCategorized: 4, + budgetEntries: 5, + transactions: 60, + }); + }); + + 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])); + 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")) + .map((c) => (c[1] as unknown[])[0]); + expect(prefKeys).toEqual( + expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]), + ); + }); + + it("reports importFolderSet=false and hidden=false without preferences", async () => { + wireSelect({}); + const snapshot = await getOnboardingCounters(); + expect(snapshot.importFolderSet).toBe(false); + expect(snapshot.hidden).toBe(false); + }); + + 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); + }); + + it("treats an empty import_folder value as unset", async () => { + wireSelect({ import_folder: "" }); + const snapshot = await getOnboardingCounters(); + expect(snapshot.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); + }); + + it("falls back to zero counters when the driver returns no row", async () => { + wireSelect({}, null); + const snapshot = await getOnboardingCounters(); + expect(snapshot.counters).toEqual({ + sources: 0, + imports: 0, + manualCategorized: 0, + budgetEntries: 0, + transactions: 0, + }); + }); +}); + +describe("hideChecklist / showChecklist (#351)", () => { + it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await hideChecklist(); + expect(mockExecute).toHaveBeenCalledTimes(1); + const [sql, params] = mockExecute.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 }); + await showChecklist(); + expect(mockExecute).toHaveBeenCalledTimes(1); + const [sql, params] = mockExecute.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")); + await expect(hideChecklist()).rejects.toThrow("database is locked"); + expect(logInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/onboardingService.ts b/src/services/onboardingService.ts new file mode 100644 index 0000000..2807da4 --- /dev/null +++ b/src/services/onboardingService.ts @@ -0,0 +1,85 @@ +import { getDb } from "./db"; +import { + deletePreference, + getImportFolder, + getPreference, + setPreference, +} from "./userPreferenceService"; +import { logInfo } from "./logService"; +import type { OnboardingCounters } from "../shared/types"; + +/** + * Getting-started checklist data (#351). + * + * 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. + */ + +/** `user_preferences` key set to "1" when the user dismissed the checklist. */ +export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden"; + +export interface OnboardingSnapshot { + counters: OnboardingCounters; + /** True when an import folder has been chosen. */ + importFolderSet: boolean; + /** True when the checklist was dismissed by the user. */ + hidden: boolean; +} + +interface CountersRow { + sources: number; + imports: number; + manual_categorized: number; + budget_entries: number; + transactions: number; +} + +const COUNTERS_SQL = ` + SELECT + (SELECT COUNT(*) FROM import_sources) AS sources, + (SELECT COUNT(*) FROM imported_files WHERE status = 'completed') AS imports, + (SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized, + (SELECT COUNT(*) FROM budget_entries) AS budget_entries, + (SELECT COUNT(*) FROM transactions) AS transactions +`; + +async function readCounters(): Promise { + const db = await getDb(); + const rows = await db.select(COUNTERS_SQL); + const row = rows[0]; + return { + sources: row?.sources ?? 0, + imports: row?.imports ?? 0, + manualCategorized: row?.manual_categorized ?? 0, + budgetEntries: row?.budget_entries ?? 0, + transactions: row?.transactions ?? 0, + }; +} + +/** Reads the counters, the import folder and the hidden flag in parallel. */ +export async function getOnboardingCounters(): Promise { + const [counters, importFolder, hiddenPref] = await Promise.all([ + readCounters(), + getImportFolder(), + getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY), + ]); + return { + counters, + importFolderSet: importFolder !== null && importFolder !== "", + hidden: hiddenPref === "1", + }; +} + +/** Dismisses the checklist 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). */ +export async function showChecklist(): Promise { + await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); + logInfo("Onboarding: checklist shown"); +} diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts new file mode 100644 index 0000000..ed615e5 --- /dev/null +++ b/src/services/transactionService.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); + +import { getDb } from "./db"; +import { getTransactionCount } from "./transactionService"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); +}); + +describe("getTransactionCount (#351)", () => { + it("returns 0 on an empty profile", async () => { + mockSelect.mockResolvedValueOnce([{ cnt: 0 }]); + await expect(getTransactionCount()).resolves.toBe(0); + }); + + it("returns the unfiltered COUNT(*) of transactions", async () => { + mockSelect.mockResolvedValueOnce([{ cnt: 1234 }]); + await expect(getTransactionCount()).resolves.toBe(1234); + expect(mockSelect).toHaveBeenCalledTimes(1); + const sql = String(mockSelect.mock.calls[0][0]); + expect(sql).toMatch(/SELECT COUNT\(\*\) AS cnt FROM transactions/); + expect(sql).not.toMatch(/WHERE/i); + }); + + it("falls back to 0 when the driver returns no row", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(getTransactionCount()).resolves.toBe(0); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 34e9ca8..c10ee08 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -330,6 +330,19 @@ export async function getAllImportSources(): Promise { ); } +/** + * Total number of transactions in the active profile, without any filter. + * Used by the onboarding checklist (#351) to tell an empty profile apart from + * a populated one. + */ +export async function getTransactionCount(): Promise { + const db = await getDb(); + const rows = await db.select>( + "SELECT COUNT(*) AS cnt FROM transactions" + ); + return rows[0]?.cnt ?? 0; +} + export async function autoCategorizeTransactions(): Promise { const db = await getDb(); const uncategorized = await db.select>( diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d33d9c1..0bb858b 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -952,3 +952,33 @@ export interface AccountReturn { is_partial: boolean; has_no_transfers_warning: boolean; } + +// --------------------------------------------------------------------------- +// Onboarding checklist (#351) +// --------------------------------------------------------------------------- + +/** Aggregated counters behind the getting-started checklist, read in one query. */ +export interface OnboardingCounters { + sources: number; + imports: number; + manualCategorized: number; + budgetEntries: number; + transactions: number; +} + +export type 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; + done: boolean; + /** True when the feature behind the row is not part of the current edition. */ + locked: boolean; + to: string; +} diff --git a/src/utils/onboardingChecklist.test.ts b/src/utils/onboardingChecklist.test.ts new file mode 100644 index 0000000..13dc401 --- /dev/null +++ b/src/utils/onboardingChecklist.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { computeChecklist } from "./onboardingChecklist"; +import type { ChecklistItemKey, OnboardingCounters } from "../shared/types"; + +const zero: OnboardingCounters = { + sources: 0, + imports: 0, + manualCategorized: 0, + budgetEntries: 0, + transactions: 0, +}; + +const open = { importFolderSet: false, budgetAllowed: true }; + +function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] { + return computeChecklist(counters, opts).items.map((i) => i.key); +} + +function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] { + return computeChecklist(counters, opts) + .items.filter((i) => i.done) + .map((i) => i.key); +} + +describe("computeChecklist — rows (#351)", () => { + it("lists the six rows in order", () => { + expect(keys(zero)).toEqual([ + "profile", + "importFolder", + "source", + "import", + "manualCategory", + "budget", + ]); + }); + + it("zero counters → only profile is done (1/6)", () => { + const list = computeChecklist(zero, open); + expect(doneKeys(zero)).toEqual(["profile"]); + expect(list.done).toBe(1); + expect(list.total).toBe(6); + expect(list.complete).toBe(false); + }); + + it("import folder chosen without any source → done=2", () => { + const list = computeChecklist(zero, { ...open, importFolderSet: true }); + expect(doneKeys(zero, { ...open, importFolderSet: true })).toEqual([ + "profile", + "importFolder", + ]); + expect(list.done).toBe(2); + }); + + it("source row follows sources ≥ 1", () => { + expect(doneKeys({ ...zero, sources: 1 })).toContain("source"); + expect(doneKeys({ ...zero, sources: 1 })).not.toContain("import"); + }); + + it("import row follows imports ≥ 1", () => { + expect(doneKeys({ ...zero, imports: 1 })).toContain("import"); + expect(doneKeys({ ...zero, imports: 1 })).not.toContain("source"); + }); + + it("manualCategory row follows manualCategorized ≥ 1 (not transactions)", () => { + expect(doneKeys({ ...zero, transactions: 50 })).not.toContain("manualCategory"); + expect(doneKeys({ ...zero, manualCategorized: 1 })).toContain("manualCategory"); + }); + + it("budget row follows budgetEntries ≥ 1", () => { + expect(doneKeys({ ...zero, budgetEntries: 1 })).toContain("budget"); + }); + + it("links each row to its page", () => { + const to = Object.fromEntries( + computeChecklist(zero, open).items.map((i) => [i.key, i.to]), + ); + expect(to).toEqual({ + profile: "/settings/users", + importFolder: "/import", + source: "/import", + import: "/import", + manualCategory: "/transactions", + budget: "/budget", + }); + }); + + it("no row is locked when Budget is allowed", () => { + expect(computeChecklist(zero, open).items.every((i) => !i.locked)).toBe(true); + }); +}); + +describe("computeChecklist — budget gating (#351)", () => { + const locked = { importFolderSet: false, budgetAllowed: false }; + + it("budgetAllowed=false → total=5 and the budget row is locked", () => { + const list = computeChecklist(zero, locked); + expect(list.total).toBe(5); + const budget = list.items.find((i) => i.key === "budget")!; + expect(budget.locked).toBe(true); + expect(budget.to).toBe("/settings/users"); + expect(list.items.filter((i) => i.locked)).toHaveLength(1); + }); + + it("a locked budget row never counts as done, even with entries", () => { + const list = computeChecklist({ ...zero, budgetEntries: 3 }, locked); + expect(list.done).toBe(1); + }); + + it("complete is true at 5/5 when Budget is locked", () => { + const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 }; + const list = computeChecklist(all, { ...locked, importFolderSet: true }); + expect(list.done).toBe(5); + expect(list.total).toBe(5); + expect(list.complete).toBe(true); + }); + + it("complete is false at 5/6 when Budget is open but empty", () => { + const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 }; + const list = computeChecklist(all, { ...open, importFolderSet: true }); + expect(list.done).toBe(5); + expect(list.total).toBe(6); + expect(list.complete).toBe(false); + }); + + it("complete is true at 6/6 when Budget is open", () => { + const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 }; + const list = computeChecklist(all, { ...open, importFolderSet: true }); + expect(list.done).toBe(6); + expect(list.total).toBe(6); + expect(list.complete).toBe(true); + }); +}); diff --git a/src/utils/onboardingChecklist.ts b/src/utils/onboardingChecklist.ts new file mode 100644 index 0000000..4105e37 --- /dev/null +++ b/src/utils/onboardingChecklist.ts @@ -0,0 +1,60 @@ +import type { ChecklistItem, OnboardingCounters } from "../shared/types"; + +/** + * Pure computation of the getting-started checklist (#351). + * + * Six rows in a fixed order. The `profile` row is always done (the checklist + * 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. + */ + +export interface ChecklistOptions { + /** An import folder has been chosen. */ + importFolderSet: boolean; + /** The Budget feature is part of the current edition. */ + budgetAllowed: boolean; +} + +export interface Checklist { + items: ChecklistItem[]; + /** Number of unlocked rows that are done. */ + done: number; + /** Number of unlocked rows (6, or 5 when Budget is locked). */ + total: number; + /** True when every unlocked row is done. */ + complete: boolean; +} + +export function computeChecklist( + counters: OnboardingCounters, + options: ChecklistOptions +): Checklist { + const budgetLocked = !options.budgetAllowed; + 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", + }, + ]; + + const unlocked = items.filter((item) => !item.locked); + const done = unlocked.filter((item) => item.done).length; + const total = unlocked.length; + + return { items, done, total, complete: done === total }; +}