Simpl-Resultat/src/utils/onboardingPath.ts
le king fu a58f8b98bc fix(onboarding): count an import only once the file brought a transaction
The "Import a first file" step counted `imported_files` rows in status
`completed`. `executeImport` writes that row before its first insert, so
an import that added nothing (a file holding only its header, or one
whose rows were all in error in the preview) ticked the step: the Import
card then said "Done." at rest and sent the user to /transactions, which
had nothing to categorize and led back to the import.

The counter now keeps a completed file only when a transaction points at
it (`EXISTS` on `transactions.file_id`, covered by
`idx_transactions_file`). Replayed with sqlite3 on
`consolidated_schema.sql`: 0 after an import without a valid row, 0
before a successful import's first insert and 1 after, 0 once the file
turns to `error`. Duplicates only change nothing: their transactions
came with an earlier completed file, and a restore writes its own.

The tests run without SQLite, so the rule is pinned on the query, and
the Import page is pinned at rest after such an import: step 4 again,
on the first source card, without "Done.".

Review of #398 (PR #404), option A chosen by Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00

433 lines
17 KiB
TypeScript

import type { Checklist } from "./onboardingChecklist";
import type {
BudgetCardInput,
BudgetOnboardingTargetKind,
ChecklistItem,
ChecklistItemKey,
ImportCardInput,
OnboardingStatus,
TransactionFilters,
} from "../shared/types";
/**
* Pure rules of the guided getting-started path (#396, #398, #399, #400).
*
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
* the step cards and the pages that carry a card only read what they decide.
* Nothing here touches React, the router or the database.
*/
/**
* The next step: the first unlocked row neither done nor skipped, or null when
* there is none left. Its link is `ChecklistItem.to`.
*/
export function nextStep(checklist: Checklist): ChecklistItem | null {
return checklist.items.find((item) => !item.locked && !item.done && !item.skipped) ?? null;
}
/**
* Routes whose page carries a step card. Each card issue adds its page (#398:
* Import, #399: Transactions, #400: Budget), and a page without a delivered
* card keeps the banner, so a partly merged stack never removes any guidance.
*/
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
"/import",
"/transactions",
"/budget",
]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"],
"/transactions": ["manualCategory"],
"/budget": ["budget"],
};
/** The steps a page guides, in checklist order (none for any other page). */
export function stepsOfPage(pathname: string): readonly ChecklistItemKey[] {
return Object.prototype.hasOwnProperty.call(PAGE_STEPS, pathname) ? PAGE_STEPS[pathname] : [];
}
export interface CardLatchInput {
pathname: string;
nextStep: ChecklistItem | null;
hidden: boolean;
/** Pages that carry a card. Defaults to `CARD_PAGES`; tests pass their own. */
cardPages?: ReadonlySet<string>;
}
/**
* The card latch: the page that carries a step card, or null.
*
* - It is set when `pathname` is the route of the next step and is in the
* card pages.
* - It is released when the route changes, when the path is hidden, and after
* "Skip" when the next step is no longer on this page: the skip action clears
* the latch (`previous` = null), and this function sets it again only if the
* new next step is on the same page.
* - A step that gets done does not release it: the card stays to say "Done."
* and offer the next step, even when that step lives on another page.
*
* Idempotent: applying it to its own result changes nothing, which lets the
* provider re-run it on every render.
*/
export function latchCardPage(previous: string | null, input: CardLatchInput): string | null {
const { pathname, nextStep: next, hidden } = input;
const cardPages = input.cardPages ?? CARD_PAGES;
if (hidden) return null;
if (previous !== null && previous === pathname) return previous;
if (next !== null && next.to === pathname && cardPages.has(pathname)) return pathname;
return null;
}
export type BannerMode = "none" | "banner" | "card" | "complete";
export interface BannerModeInput {
status: OnboardingStatus;
checklist: Checklist | null;
hidden: boolean;
pathname: string;
/** The dashboard of a profile without any transaction (D11). */
isEmptyDashboard: boolean;
/** False until the Budget entitlement is known. */
budgetReady: boolean;
/** The latched card page (`latchCardPage`). */
cardPage: string | null;
}
/**
* What the path shows on the current page:
* - `none` before the first successful load, when the path is hidden (D8, D9),
* and on the empty dashboard, whose welcome card already holds the list (D11);
* - `card` on the latched card page, even once every step is done (the card
* then says so itself);
* - `complete` when every unlocked step is done or skipped (D4), and only once
* Budget's entitlement is known: until then the checklist is computed with
* Budget locked, and "complete" could be a sixth step away;
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
*
* A failed re-read after a first success keeps status `ready` (reducer), so
* the last state stays on screen.
*/
export function bannerMode(input: BannerModeInput): BannerMode {
const { status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage } = input;
if (status !== "ready" || checklist === null) return "none";
if (hidden) return "none";
if (isEmptyDashboard) return "none";
if (cardPage !== null && cardPage === pathname) return "card";
if (checklist.complete) return budgetReady ? "complete" : "none";
return "banner";
}
/** What a page may read from `location.state` about the path. */
export interface OnboardingNavState {
/** Move the focus to the step card's title. */
focus: boolean;
/** Open the page in its guided entry (Transactions: uncategorized only). */
guided: boolean;
}
/** What a link to a step puts in `location.state`, read back by `readOnboardingNavState`. */
export interface OnboardingLinkState {
onboarding: { focus: true; guided?: true };
}
/**
* The navigation state of every link to a step's page (#397): the banner's
* next step, the rows of the list and their "Resume". The step card of the
* page takes the focus, and the "categorize by hand" step also opens
* Transactions in its guided entry, wherever its link was clicked.
*/
export function stepNavState(key: ChecklistItemKey): OnboardingLinkState {
return key === "manualCategory"
? { onboarding: { focus: true, guided: true } }
: { onboarding: { focus: true } };
}
/**
* The only way to read `location.state` (typed `any` by react-router) for the
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
* `{ onboarding: { guided: true } }` reads as false, whatever its shape.
*/
export function readOnboardingNavState(state: unknown): OnboardingNavState {
if (typeof state !== "object" || state === null) return { focus: false, guided: false };
const onboarding: unknown = (state as { onboarding?: unknown }).onboarding;
if (typeof onboarding !== "object" || onboarding === null) {
return { focus: false, guided: false };
}
const flags = onboarding as { focus?: unknown; guided?: unknown };
return { focus: flags.focus === true, guided: flags.guided === true };
}
// ---------------------------------------------------------------------------
// Step cards (#398)
// ---------------------------------------------------------------------------
/**
* The step card shows on this page: the path is in card mode, latched on this
* very route. The connected card renders only then, and the page highlights
* its model's target only then: no ring without the card that explains it.
*/
export function showsStepCard(mode: BannerMode, cardPage: string | null, pathname: string): boolean {
return mode === "card" && cardPage === pathname;
}
/** The step a page's card shows, and its place in the path ("Step n of N"). */
export interface CardStep {
step: ChecklistItem;
/** Rank of the step among the unlocked rows, from 1, in checklist order. */
n: number;
/** Number of unlocked rows. */
total: number;
}
/**
* The step a page's card shows: the first of the page's steps neither done
* nor skipped, or the page's last step once none is left (on /import:
* `importFolder`, `source`, `import`, then `import`). A skipped step is passed
* over as `nextStep` does: after "Skip" the card latches again when the new
* next step is on the same page, and must then show that step, not the
* skipped one with its "Skip" button again. Null for a page that guides no
* unlocked step.
*/
export function cardStep(checklist: Checklist, pathname: string): CardStep | null {
const pageSteps = stepsOfPage(pathname);
const unlocked = checklist.items.filter((item) => !item.locked);
const ofPage = unlocked.filter((item) => pageSteps.includes(item.key));
if (ofPage.length === 0) return null;
const step = ofPage.find((item) => !item.done && !item.skipped) ?? ofPage[ofPage.length - 1];
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
}
/** What follows "Done." on a step card. */
export type StepCardNext =
| { kind: "step"; step: ChecklistItem }
| { kind: "end" }
| { kind: "wait" };
/**
* What follows "Done." on a page's card:
* - `step`: the next step, which lives on another page;
* - `end`: no step left (end mode), once the Budget entitlement is known:
* until then the checklist counts Budget as locked, and the end could be a
* sixth step away (the rule of `bannerMode`);
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
* while the next step is still one of this page's own: the re-read that
* follows the write that completed them (ADR 0022) has not landed, and a
* link would lead back to this very page.
*/
export function cardNext(
nextStep: ChecklistItem | null,
pathname: string,
budgetReady: boolean,
): StepCardNext {
if (nextStep === null) return budgetReady ? { kind: "end" } : { kind: "wait" };
if (stepsOfPage(pathname).includes(nextStep.key)) return { kind: "wait" };
return { kind: "step", step: nextStep };
}
/** A sentence of a step card: an i18n key and its interpolations. */
export interface StepCardText {
key: string;
/**
* Interpolations that name a control: the i18n key of its label, translated
* when the card renders, so a button's label is never copied into a sentence.
*/
labels?: Readonly<Record<string, string>>;
/** Interpolations shown as they are (a folder name). */
values?: Readonly<Record<string, string>>;
}
/** What a page tells its step card. */
export interface StepCardModel {
/** The sentence; null once the page's steps are done, "Done." then says it. */
text: StepCardText | null;
/** The page's steps are done: "Done." and what follows it (`cardNext`). */
done: boolean;
}
/** The control of the Import page that the card points at. */
export type ImportCardTarget = "emptyStateAction" | "firstSource" | "wizardNext";
export interface ImportCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: ImportCardTarget | null;
}
const IMPORT_DONE: ImportCardModel = { text: null, done: true, target: null };
function importText(name: string, rest: Omit<StepCardText, "key"> = {}): StepCardText {
return { key: `onboarding.card.import.${name}`, ...rest };
}
/**
* The wizard steps that end on its next button: their sentence and the label
* of that button, the one `ImportPage` gives the button at that step.
*/
const WIZARD_ROWS = {
"source-config": { text: "sourceConfig", button: "import.wizard.preview" },
"file-preview": { text: "filePreview", button: "import.wizard.checkDuplicates" },
"duplicate-check": { text: "duplicateCheck", button: "import.wizard.confirm" },
confirm: { text: "confirm", button: "import.wizard.import" },
} as const;
/** The checklist marks the step done (a skipped step is not done). */
function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
return checklist?.items.some((item) => item.key === key && item.done) ?? false;
}
/**
* The Import page's card, read from the wizard and the checklist. The rows
* are tried in order, and the first that applies wins:
* 1. the folder is not read yet: no card (null);
* 2. an import is running: "Importing…". It comes before anything the
* counters say, because the import step ticks as soon as the first
* transaction of the file lands, long before the import ends;
* 3. the report: "Done." when the import added transactions, the failure
* sentence when it added none (insertion failed, duplicates only), or when
* the count is missing: never "Done." without one;
* 4. no folder: choose one, with the empty state's button;
* 5. the wizard at rest (`source-list`) with the source and import steps
* checked: "Done.". This is the table's last row ("steps 3 and 4 already
* checked, outside the wizard"): placed after the rows that cover every
* wizard step, it could never apply, and "Done" or "Cancel" at the end of
* an import would send the card back to "click your bank's card";
* 6. a folder without bank sub-folder: create them, or the sample folder;
* 7. the wizard: the first source card, then its next button at each step.
*/
export function importCardModel(
input: ImportCardInput,
checklist: Checklist | null,
): ImportCardModel | null {
const step = input.wizardStep;
if (!input.folderLoaded) return null;
if (input.importing || step === "importing") {
return { text: importText("importing"), done: false, target: null };
}
if (step === "report") {
return input.importedCount !== null && input.importedCount > 0
? IMPORT_DONE
: { text: importText("failed"), done: false, target: null };
}
if (!input.hasFolder) {
return {
text: importText("noFolder", { labels: { button: "import.empty.noFolder.cta" } }),
done: false,
target: "emptyStateAction",
};
}
if (step === "source-list" && checked(checklist, "source") && checked(checklist, "import")) {
return IMPORT_DONE;
}
if (input.sourceCount === 0) {
return {
text: importText("noSources", {
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
values: { folder: input.folderName ?? "" },
}),
done: false,
target: "emptyStateAction",
};
}
if (step === "source-list") {
return { text: importText("sourceList"), done: false, target: "firstSource" };
}
const row = WIZARD_ROWS[step];
return {
text: importText(row.text, { labels: { button: row.button } }),
done: false,
target: "wizardNext",
};
}
/** The control of the Transactions page that the card points at. */
export type TransactionsCardTarget = "firstRowCategory";
export interface TransactionsCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: TransactionsCardTarget | null;
}
/**
* The Transactions page's card (#399), read from the page's filters and the
* checklist:
* - the "categorize by hand" step checked: "Done.", nothing to point at;
* - otherwise, the sentence that matches the list: the uncategorized
* transactions only (the guided entry, or the filter picked by hand), the
* filter named by its own label, or every transaction (an ordinary visit,
* or a guided entry that found none uncategorized); and the category of the
* first row shown.
*/
export function transactionsCardModel(
filters: Pick<TransactionFilters, "uncategorizedOnly">,
checklist: Checklist | null,
): TransactionsCardModel {
if (checked(checklist, "manualCategory")) return { text: null, done: true, target: null };
return {
text: filters.uncategorizedOnly
? {
key: "onboarding.card.transactions.uncategorized",
labels: { filter: "transactions.filters.uncategorized" },
}
: { key: "onboarding.card.transactions.all" },
done: false,
target: "firstRowCategory",
};
}
export interface BudgetCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: BudgetOnboardingTargetKind | null;
}
/**
* The Budget page's card (#400), read from the grid's target and the
* checklist:
* - the budget step checked: "Done.", nothing to point at; an entry of 0
* counts, as it always has (D13);
* - "Expand all" pointed at: the sentence that starts by unfolding the
* grid, the button named by its own label;
* - a month cell pointed at: the sentence for a grid whose editable rows
* show;
* - no target (the grid is not on the page, has no row, or has not said yet):
* no sentence and no ring; the title and "Skip this step" stay.
*/
export function budgetCardModel(
input: BudgetCardInput,
checklist: Checklist | null,
): BudgetCardModel {
if (checked(checklist, "budget")) return { text: null, done: true, target: null };
switch (input.target) {
case "expandAll":
return {
text: {
key: "onboarding.card.budget.collapsed",
labels: { expand: "reports.collapse.expandAll" },
},
done: false,
target: "expandAll",
};
case "cell":
return { text: { key: "onboarding.card.budget.flat" }, done: false, target: "cell" };
default:
return { text: null, done: false, target: null };
}
}
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
export interface OnboardingTargetAttributes {
"data-onboarding-target"?: string;
"data-onboarding-highlight"?: "true";
}
/**
* What a target control renders: its key and the highlight when the page
* passes `onboardingTarget`, nothing otherwise. The page picks the target
* from its card model and hands it down through props, never by a DOM query:
* a target that is not on screen leaves the card without a ring.
*/
export function onboardingTargetAttributes(
key: string,
active: boolean | undefined,
): OnboardingTargetAttributes {
return active ? { "data-onboarding-target": key, "data-onboarding-highlight": "true" } : {};
}