feat(onboarding): transaction count, onboarding counters and checklist reducer (#351) #377

Merged
maximus merged 2 commits from issue-351-onboarding-core into main 2026-10-04 12:51:11 +00:00
9 changed files with 784 additions and 0 deletions
Showing only changes of commit f77ce94505 - Show all commits

View file

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

View file

@ -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,
};
}

View file

@ -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<string, string>, 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();
});
});

View file

@ -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<OnboardingCounters> {
const db = await getDb();
const rows = await db.select<CountersRow[]>(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<OnboardingSnapshot> {
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<void> {
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
logInfo("Onboarding: checklist hidden");
}
/** Shows the checklist again (removes the dismissal preference). */
export async function showChecklist(): Promise<void> {
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
logInfo("Onboarding: checklist shown");
}

View file

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

View file

@ -330,6 +330,19 @@ export async function getAllImportSources(): Promise<ImportSource[]> {
); );
} }
/**
* 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<number> {
const db = await getDb();
const rows = await db.select<Array<{ cnt: number }>>(
"SELECT COUNT(*) AS cnt FROM transactions"
);
return rows[0]?.cnt ?? 0;
}
export async function autoCategorizeTransactions(): Promise<number> { export async function autoCategorizeTransactions(): Promise<number> {
const db = await getDb(); const db = await getDb();
const uncategorized = await db.select<Array<{ id: number; description: string }>>( const uncategorized = await db.select<Array<{ id: number; description: string }>>(

View file

@ -952,3 +952,33 @@ export interface AccountReturn {
is_partial: boolean; is_partial: boolean;
has_no_transfers_warning: 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;
}

View file

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

View file

@ -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 };
}