docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407

Merged
maximus merged 11 commits from issue-401-onboarding-guide-qa into main 2026-10-09 01:11:03 +00:00
6 changed files with 44 additions and 7 deletions
Showing only changes of commit a58f8b98bc - Show all commits

View file

@ -230,7 +230,8 @@ describe("ImportPage — the step card (#398)", () => {
scannedSources: SOURCES, scannedSources: SOURCES,
importReport: report(0), importReport: report(0),
}); });
// Duplicates only: the file row is `completed`, the checklist counts it. // Duplicates only: this file brought no transaction, and the step stays
// checked by the earlier import that brought the ones it duplicates.
harness.onboarding = path(IMPORTED, true); harness.onboarding = path(IMPORTED, true);
const html = render(); const html = render();
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])"); expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
@ -238,6 +239,20 @@ describe("ImportPage — the step card (#398)", () => {
expect(html).not.toContain("onboarding.card.next"); expect(html).not.toContain("onboarding.card.next");
}); });
it("after 'Done' on an import that added nothing, the wizard at rest: no 'Done.', step 4 again on the first source card", () => {
// The file row is written, but the import step only counts a file that
// brought a transaction: the re-read keeps the step to do (review of #398).
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html);
expect(target.inner).toContain("Desjardins");
expect(target.inner).not.toContain("Tangerine");
});
it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => { it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(IMPORTED, true); harness.onboarding = path(IMPORTED, true);

View file

@ -39,8 +39,9 @@ export async function createImportedFile(file: {
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2", "SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
[file.source_id, file.filename] [file.source_id, file.filename]
); );
// Both branches write the status that the "import a first file" step counts // Both branches write the status that the "import a first file" step reads
// (`status = 'completed'`), so each one is announced (ADR 0022). // (`status = 'completed'`, counted once the file brought a transaction), so
// each one is announced (ADR 0022).
if (existing.length > 0) { if (existing.length > 0) {
await db.execute( await db.execute(
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`, `UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,

View file

@ -131,7 +131,6 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
expect(db.select).toHaveBeenCalledTimes(4); expect(db.select).toHaveBeenCalledTimes(4);
const sqls = db.select.mock.calls.map((c) => String(c[0])); const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!; 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 transactions WHERE is_manually_categorized = 1");
expect(aggregate).toContain("FROM budget_entries"); expect(aggregate).toContain("FROM budget_entries");
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/); expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
@ -144,6 +143,21 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1); expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
}); });
// The tests run without SQLite, so the rule is pinned on the query itself.
// `executeImport` writes the file row `completed` before its first insert: a
// file whose rows were all in error keeps that row and adds no transaction,
// and must not tick "Import a first file" (review of #398).
it("counts a completed import only once the file brought a transaction", async () => {
const { db } = useFakeDb();
await getOnboardingSnapshot();
const aggregate = db.select.mock.calls
.map((c) => String(c[0]))
.find((s) => s.includes("import_sources"))!;
expect(aggregate).toMatch(
/\(SELECT COUNT\(\*\) FROM imported_files f\s+WHERE f\.status = 'completed'\s+AND EXISTS \(SELECT 1 FROM transactions t WHERE t\.file_id = f\.id\)\)\s+AS imports/,
);
});
it("is a pure read: it never writes, even on a profile D9 would hide", async () => { it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
const { db } = useFakeDb({ transactions: 60 }); const { db } = useFakeDb({ transactions: 60 });
const unsubscribe = subscribeDataChanged(() => { const unsubscribe = subscribeDataChanged(() => {

View file

@ -54,10 +54,16 @@ interface CountersRow {
transactions: number; transactions: number;
} }
// `imports` counts a `completed` file only once it brought a transaction:
// `executeImport` writes the row `completed` before inserting, and a file whose
// rows were all in error (or that holds only its header) keeps that row with
// no transaction. The sub-query is covered by `idx_transactions_file`.
const COUNTERS_SQL = ` const COUNTERS_SQL = `
SELECT SELECT
(SELECT COUNT(*) FROM import_sources) AS sources, (SELECT COUNT(*) FROM import_sources) AS sources,
(SELECT COUNT(*) FROM imported_files WHERE status = 'completed') AS imports, (SELECT COUNT(*) FROM imported_files f
WHERE f.status = 'completed'
AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)) AS imports,
(SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized, (SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized,
(SELECT COUNT(*) FROM budget_entries) AS budget_entries, (SELECT COUNT(*) FROM budget_entries) AS budget_entries,
(SELECT COUNT(*) FROM transactions) AS transactions (SELECT COUNT(*) FROM transactions) AS transactions

View file

@ -960,6 +960,7 @@ export interface AccountReturn {
/** Aggregated counters behind the getting-started checklist, read in one query. */ /** Aggregated counters behind the getting-started checklist, read in one query. */
export interface OnboardingCounters { export interface OnboardingCounters {
sources: number; sources: number;
/** `completed` imported files that brought at least one transaction. */
imports: number; imports: number;
manualCategorized: number; manualCategorized: number;
budgetEntries: number; budgetEntries: number;

View file

@ -280,8 +280,8 @@ function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
* are tried in order, and the first that applies wins: * are tried in order, and the first that applies wins:
* 1. the folder is not read yet: no card (null); * 1. the folder is not read yet: no card (null);
* 2. an import is running: "Importing…". It comes before anything the * 2. an import is running: "Importing…". It comes before anything the
* counters say, because `executeImport` writes the `imported_files` row * counters say, because the import step ticks as soon as the first
* `completed` before inserting a single transaction; * transaction of the file lands, long before the import ends;
* 3. the report: "Done." when the import added transactions, the failure * 3. the report: "Done." when the import added transactions, the failure
* sentence when it added none (insertion failed, duplicates only), or when * sentence when it added none (insertion failed, duplicates only), or when
* the count is missing: never "Done." without one; * the count is missing: never "Done." without one;