Compare commits
No commits in common. "76fd8d376a52dff36b1ceb8d1347ae4a3cd98370" and "7a7fdf90e27fea1e2ecaabd542944bac19f756c7" have entirely different histories.
76fd8d376a
...
7a7fdf90e2
9 changed files with 0 additions and 785 deletions
|
|
@ -1,122 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
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) {
|
|
||||||
const error = errorMessage(e);
|
|
||||||
logError("Onboarding: failed to load checklist counters", error);
|
|
||||||
dispatch({ type: "LOAD_ERROR", error });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Read-only local SELECTs: a StrictMode double run is harmless.
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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,19 +330,6 @@ 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 }>>(
|
||||||
|
|
|
||||||
|
|
@ -952,33 +952,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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