feat(onboarding): transaction count, onboarding counters and checklist reducer (#351) #377
9 changed files with 784 additions and 0 deletions
122
src/hooks/useOnboardingChecklist.test.ts
Normal file
122
src/hooks/useOnboardingChecklist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
144
src/hooks/useOnboardingChecklist.ts
Normal file
144
src/hooks/useOnboardingChecklist.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
155
src/services/onboardingService.test.ts
Normal file
155
src/services/onboardingService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
85
src/services/onboardingService.ts
Normal file
85
src/services/onboardingService.ts
Normal 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");
|
||||
}
|
||||
43
src/services/transactionService.test.ts
Normal file
43
src/services/transactionService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> {
|
||||
const db = await getDb();
|
||||
const uncategorized = await db.select<Array<{ id: number; description: string }>>(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
132
src/utils/onboardingChecklist.test.ts
Normal file
132
src/utils/onboardingChecklist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
src/utils/onboardingChecklist.ts
Normal file
60
src/utils/onboardingChecklist.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue