diff --git a/src/components/import/ImportEmptyState.test.tsx b/src/components/import/ImportEmptyState.test.tsx index f07e896..f830a03 100644 --- a/src/components/import/ImportEmptyState.test.tsx +++ b/src/components/import/ImportEmptyState.test.tsx @@ -145,6 +145,32 @@ describe("ImportEmptyState — folder without sources (#356)", () => { }); }); +describe("ImportEmptyState — getting-started target (#398)", () => { + const highlighted = (tree: ReactNode) => + find(tree, (e) => e.props["data-onboarding-highlight"] === "true"); + + it("highlights 'Choose a folder' without a folder, and only it", () => { + const { tree } = render({ hasFolder: false, onboardingTarget: true }); + const targets = highlighted(tree); + expect(targets).toHaveLength(1); + expect(targets[0].type).toBe("button"); + expect(texts(targets[0].props.children)).toContain("import.empty.noFolder.cta"); + }); + + it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => { + const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true }); + const targets = highlighted(tree); + expect(targets).toHaveLength(1); + expect(texts(targets[0].props.children)).toContain("import.empty.createSample"); + }); + + it("highlights nothing without the property", () => { + expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0); + expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0); + expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0); + }); +}); + describe("folderNameFromPath (#356)", () => { it("returns the last segment for POSIX and Windows paths", () => { expect(folderNameFromPath("/home/max/releves")).toBe("releves"); diff --git a/src/components/import/ImportEmptyState.tsx b/src/components/import/ImportEmptyState.tsx index ffb5847..bd76318 100644 --- a/src/components/import/ImportEmptyState.tsx +++ b/src/components/import/ImportEmptyState.tsx @@ -16,6 +16,11 @@ export interface ImportEmptyStateProps { onRefresh: () => void; /** The sample folders are being written: the primary button is disabled. */ isCreatingSample: boolean; + /** + * Highlight the primary action for the getting-started card (#398): "Choose + * a folder" without a folder, "Create a sample folder" with one. + */ + onboardingTarget?: boolean; } /** @@ -42,6 +47,7 @@ export default function ImportEmptyState({ onCreateSample, onRefresh, isCreatingSample, + onboardingTarget, }: ImportEmptyStateProps) { const { t } = useTranslation(); @@ -62,7 +68,7 @@ export default function ImportEmptyState({ icon={} title={t("import.empty.noFolder.title")} description={t("import.empty.noFolder.description")} - primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse }} + primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }} > {guideLink} @@ -78,6 +84,7 @@ export default function ImportEmptyState({ label: t("import.empty.createSample"), onClick: onCreateSample, disabled: isCreatingSample, + onboardingTarget, }} secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }} > diff --git a/src/components/import/SourceCard.tsx b/src/components/import/SourceCard.tsx index cf0114f..a1e69c2 100644 --- a/src/components/import/SourceCard.tsx +++ b/src/components/import/SourceCard.tsx @@ -1,19 +1,26 @@ import { useTranslation } from "react-i18next"; import { FolderOpen, FileText, CheckCircle } from "lucide-react"; import type { ScannedSource } from "../../shared/types"; +import { onboardingTargetAttributes } from "../../utils/onboardingPath"; interface SourceCardProps { source: ScannedSource; isConfigured: boolean; newFileCount: number; onClick: () => void; + /** Highlight this card for the getting-started card (#398). */ + onboardingTarget?: boolean; } +/** `data-onboarding-target` of a highlighted source card. */ +export const SOURCE_CARD_TARGET = "source-card"; + export default function SourceCard({ source, isConfigured, newFileCount, onClick, + onboardingTarget, }: SourceCardProps) { const { t } = useTranslation(); @@ -21,6 +28,7 @@ export default function SourceCard({ + )} + {done && next.kind === "step" && ( + + {t("onboarding.card.next", { + step: t(`onboarding.checklist.items.${next.step.key}`), + })} + + )} + {done && next.kind === "end" && ( + <> + {t("onboarding.banner.complete")} + + {t("onboarding.banner.openGuide")} + + + + )} + + + + + ); +} diff --git a/src/components/shared/EmptyState.test.tsx b/src/components/shared/EmptyState.test.tsx index 54ab299..df6f4d3 100644 --- a/src/components/shared/EmptyState.test.tsx +++ b/src/components/shared/EmptyState.test.tsx @@ -6,9 +6,10 @@ // component: creating the element never needs a router, only rendering would. import { describe, it, expect, vi } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Link, MemoryRouter } from "react-router-dom"; -import EmptyState from "./EmptyState"; +import EmptyState, { EMPTY_STATE_TARGET, type EmptyStateAction } from "./EmptyState"; function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] { if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); @@ -109,3 +110,40 @@ describe("EmptyState", () => { expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go")); }); }); + +describe("EmptyState — getting-started target (#398)", () => { + const HIGHLIGHT = 'data-onboarding-highlight="true"'; + const count = (html: string) => html.split(HIGHLIGHT).length - 1; + const markup = (primaryAction: EmptyStateAction, secondaryAction?: EmptyStateAction) => + renderToStaticMarkup( + + + , + ); + const highlighted = (el: ReactNode) => + find(el, (e) => (e.props as Record)["data-onboarding-highlight"] === "true"); + + it("highlights the action that carries onboardingTarget, and nothing else, link or button", () => { + const secondary: EmptyStateAction = { label: "Other", onClick: vi.fn() }; + for (const primary of [ + { label: "Go", to: "/import", onboardingTarget: true }, + { label: "Do", onClick: vi.fn(), onboardingTarget: true }, + ] satisfies EmptyStateAction[]) { + const html = markup(primary, secondary); + expect(count(html), primary.label).toBe(1); + expect(html).toContain(`data-onboarding-target="${EMPTY_STATE_TARGET}"`); + + const [target, ...others] = highlighted( + EmptyState({ icon: ICON, title: "Title", primaryAction: primary, secondaryAction: secondary }), + ); + expect(others).toHaveLength(0); + expect(texts((target.props as { children?: ReactNode }).children)).toEqual([primary.label]); + } + }); + + it("highlights nothing without it, or with it false", () => { + expect(count(markup({ label: "Go", to: "/import" }, { label: "Other", onClick: vi.fn() }))).toBe(0); + expect(count(markup({ label: "Do", onClick: vi.fn(), onboardingTarget: false }))).toBe(0); + expect(markup({ label: "Do", onClick: vi.fn() })).not.toContain("data-onboarding-target"); + }); +}); diff --git a/src/components/shared/EmptyState.tsx b/src/components/shared/EmptyState.tsx index 0309053..7dfa463 100644 --- a/src/components/shared/EmptyState.tsx +++ b/src/components/shared/EmptyState.tsx @@ -1,13 +1,18 @@ import type { ReactNode } from "react"; import { Link } from "react-router-dom"; +import { onboardingTargetAttributes } from "../../utils/onboardingPath"; /** * An action of the empty state: either a route (rendered as a react-router * ``, never `useNavigate`) or a callback (rendered as a ` diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 45b5849..cb5de2c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -74,6 +74,23 @@ "complete": "Getting started complete. The user guide takes it from here.", "openGuide": "Open the guide" }, + "card": { + "stepOf": "Step {{n}} of {{total}}", + "skip": "Skip this step", + "done": "Done.", + "next": "Next step: {{step}} →", + "import": { + "noFolder": "Choose the folder where you will drop your bank statements, with the {{button}} button. Simpl'Résultat will look for one sub-folder per bank in it.", + "noSources": "In the {{folder}} folder, create one sub-folder per bank and drop your CSV statements in it, then click {{refresh}}. To try first, {{sample}} adds two fictitious banks.", + "sourceList": "Click your bank's card. Simpl'Résultat recognises the format of its statements and shows you what it understood.", + "sourceConfig": "Check the source name and the selected files, then click {{button}}.", + "filePreview": "Check that dates and amounts are read correctly, then click {{button}}.", + "duplicateCheck": "Choose what to do with transactions already imported, then click {{button}}.", + "confirm": "Click {{button}}: the source is saved and your transactions come in.", + "importing": "Importing…", + "failed": "The import added no transaction; the report below says why." + } + }, "state": { "done": "(done)", "skipped": "(skipped)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 15908d0..b55bca3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -74,6 +74,23 @@ "complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.", "openGuide": "Ouvrir le guide" }, + "card": { + "stepOf": "Étape {{n}} sur {{total}}", + "skip": "Passer cette étape", + "done": "Fait.", + "next": "Étape suivante : {{step}} →", + "import": { + "noFolder": "Choisissez le dossier où vous déposerez vos relevés bancaires, avec le bouton {{button}}. Simpl'Résultat y cherchera un sous-dossier par banque.", + "noSources": "Dans le dossier {{folder}}, créez un sous-dossier par banque et déposez-y vos relevés CSV, puis cliquez {{refresh}}. Pour essayer d'abord, {{sample}} dépose deux banques fictives.", + "sourceList": "Cliquez sur la carte de votre banque. Simpl'Résultat reconnaît le format de ses relevés et vous montre ce qu'il a compris.", + "sourceConfig": "Vérifiez le nom de la source et les fichiers cochés, puis cliquez {{button}}.", + "filePreview": "Vérifiez que les dates et les montants sont bien lus, puis cliquez {{button}}.", + "duplicateCheck": "Choisissez quoi faire des transactions déjà importées, puis cliquez {{button}}.", + "confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.", + "importing": "Import en cours…", + "failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi." + } + }, "state": { "done": "(faite)", "skipped": "(passée)", diff --git a/src/pages/ImportPage.render.test.tsx b/src/pages/ImportPage.render.test.tsx new file mode 100644 index 0000000..0ded6ae --- /dev/null +++ b/src/pages/ImportPage.render.test.tsx @@ -0,0 +1,283 @@ +// ImportPage and its getting-started card (#398). No jsdom: react-dom/server +// renders in vitest's node environment. The wizard is a fake +// `useImportWizard()` over its real initial state, the path a fake +// `useOnboarding()` built from the real checklist, and the wizard's heavy +// panels are stubbed. What is pinned is the page's wiring: where the card +// sits, what it says at each step, which control carries the one highlight, +// and what waits for the folder to be read (#384). +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; + +// Importing the real wizard module (for its initial state) pulls the import +// services, then the database: stub the connection. +vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => + o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`, + i18n: { language: "fr" }, + }), +})); + +const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown })); + +vi.mock("../hooks/useImportWizard", async (importOriginal) => ({ + ...(await importOriginal()), + useImportWizard: () => harness.wizard, +})); +vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); + +const { stub } = vi.hoisted(() => ({ + stub: (name: string) => ({ default: () => }), +})); +vi.mock("../components/import/ImportHistoryPanel", () => stub("history")); +vi.mock("../components/import/SourceConfigPanel", () => stub("source-config")); +vi.mock("../components/import/FilePreviewTable", () => stub("file-preview")); +vi.mock("../components/import/DuplicateCheckPanel", () => stub("duplicate-check")); +vi.mock("../components/import/ImportConfirmation", () => stub("confirmation")); +vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); + +import ImportPage from "./ImportPage"; +import { initialWizardState, type WizardState } from "../hooks/useImportWizard"; +import type { OnboardingValue } from "../hooks/useOnboardingChecklist"; +import { computeChecklist } from "../utils/onboardingChecklist"; +import { nextStep } from "../utils/onboardingPath"; +import type { ImportReport, OnboardingCounters, ScannedSource } from "../shared/types"; + +const ZERO: OnboardingCounters = { + sources: 0, + imports: 0, + manualCategorized: 0, + budgetEntries: 0, + transactions: 0, +}; +/** Source and import checked (the re-read after a successful import). */ +const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 }; + +const SOURCES: ScannedSource[] = ["Desjardins", "Tangerine"].map((name) => ({ + folder_name: name, + folder_path: `/home/max/Relevés/${name}`, + files: [], +})); + +const report = (importedCount: number): ImportReport => ({ + totalRows: 41, + importedCount, + skippedDuplicates: 41 - importedCount, + errorCount: 0, + categorizedCount: importedCount, + uncategorizedCount: 0, + errors: [], +}); + +const DUPLICATES = { fileAlreadyImported: false, duplicateRows: [], newRows: [] }; + +/** The wizard after its mount read, with `overrides`. */ +function wizard(overrides: Partial = {}) { + const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides }; + const noop = vi.fn(); + return new Proxy({ state } as Record, { + get: (target, key) => (key in target ? target[key as string] : noop), + }); +} + +/** The path latched on /import (card mode), as the provider shares it. */ +function path( + counters: OnboardingCounters, + importFolderSet: boolean, + overrides: Partial = {}, +): OnboardingValue { + const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true }); + return { + status: "ready", + checklist, + counters, + hidden: false, + budgetReady: true, + nextStep: nextStep(checklist), + mode: "card", + cardPage: "/import", + hide: vi.fn(async () => undefined), + show: vi.fn(async () => undefined), + skip: vi.fn(async () => undefined), + resume: vi.fn(async () => undefined), + refresh: vi.fn(async () => undefined), + ...overrides, + }; +} + +const render = () => + renderToStaticMarkup( + + + , + ); + +const CARD = 'aria-label="t(onboarding.banner.label)"'; +const HIGHLIGHT = 'data-onboarding-highlight="true"'; +const count = (html: string, needle: string) => html.split(needle).length - 1; + +/** The one highlighted control: its tag and its inner markup. */ +function highlighted(html: string): { tag: string; inner: string } { + expect(count(html, HIGHLIGHT)).toBe(1); + const match = html.match(/<(button|a) [^>]*data-onboarding-highlight="true"[^>]*>(.*?)<\/\1>/); + expect(match).not.toBeNull(); + return { tag: match![1], inner: match![2] }; +} + +/** + * The card's sentence: the paragraph that follows its title. The card always + * hands `t` its interpolations, so a sentence without any reads `t(key[])`. + */ +const sentence = (html: string) => html.match(/<\/h2>

]*>(.*?)<\/p>/)?.[1] ?? null; + +beforeEach(() => { + harness.wizard = wizard(); + harness.onboarding = path(ZERO, false); +}); + +describe("ImportPage — the step card (#398)", () => { + it("renders the card first, before the page's heading", () => { + const html = render(); + expect(count(html, CARD)).toBe(1); + expect(html.indexOf(CARD)).toBeLessThan(html.indexOf(" { + const html = render(); + expect(sentence(html)).toBe( + "t(onboarding.card.import.noFolder[button=t(import.empty.noFolder.cta)])", + ); + const target = highlighted(html); + expect(target.tag).toBe("button"); + expect(target.inner).toBe("t(import.empty.noFolder.cta)"); + }); + + it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => { + harness.wizard = wizard({ importFolder: "/home/max/Relevés" }); + harness.onboarding = path(ZERO, true); + const html = render(); + expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])"); + expect(sentence(html)).toBe( + "t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])", + ); + expect(highlighted(html).inner).toBe("t(import.empty.createSample)"); + }); + + it("sources found: the first source card", () => { + harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); + harness.onboarding = path(ZERO, true); + const html = render(); + 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.each([ + ["source-config", "sourceConfig", "import.wizard.preview"], + ["file-preview", "filePreview", "import.wizard.checkDuplicates"], + ["duplicate-check", "duplicateCheck", "import.wizard.confirm"], + ["confirm", "confirm", "import.wizard.import"], + ] as const)("%s: the next button, the one the sentence names", (step, text, label) => { + harness.wizard = wizard({ + step, + importFolder: "/home/max/Relevés", + scannedSources: SOURCES, + selectedSource: SOURCES[0], + duplicateResult: DUPLICATES, + }); + harness.onboarding = path({ ...ZERO, sources: 0 }, true); + const html = render(); + expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`); + expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`); + const target = highlighted(html); + expect(target.tag).toBe("button"); + expect(target.inner).toContain(`t(${label})`); + }); + + it("importing: the card says so and points at nothing, though the counters already tick", () => { + harness.wizard = wizard({ step: "importing", importFolder: "/home/max/Relevés", scannedSources: SOURCES }); + harness.onboarding = path(IMPORTED, true); + const html = render(); + expect(html).toContain("t(import.progress.title)"); + expect(sentence(html)).toBe("t(onboarding.card.import.importing[])"); + expect(html).not.toContain("t(onboarding.card.done)"); + expect(count(html, HIGHLIGHT)).toBe(0); + }); + + it("the report of an import that added transactions: 'Done.' and the next step", () => { + harness.wizard = wizard({ + step: "report", + importFolder: "/home/max/Relevés", + scannedSources: SOURCES, + importReport: report(41), + }); + harness.onboarding = path(IMPORTED, true); + const html = render(); + expect(html).toMatch(/

]*>.*t\(onboarding\.card\.done\)/); + expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/); + expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])"); + expect(count(html, HIGHLIGHT)).toBe(0); + }); + + it("the report of an import that added nothing: the failure sentence, never 'Done.'", () => { + harness.wizard = wizard({ + step: "report", + importFolder: "/home/max/Relevés", + scannedSources: SOURCES, + importReport: report(0), + }); + // Duplicates only: the file row is `completed`, the checklist counts it. + harness.onboarding = path(IMPORTED, true); + const html = render(); + expect(sentence(html)).toBe("t(onboarding.card.import.failed[])"); + expect(html).not.toContain("t(onboarding.card.done)"); + expect(html).not.toContain("onboarding.card.next"); + }); + + 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.onboarding = path(IMPORTED, true); + const html = render(); + expect(html).toContain("t(onboarding.card.done)"); + expect(html).toContain("Desjardins"); + expect(count(html, HIGHLIGHT)).toBe(0); + }); + + it("without the card on screen, no control is highlighted", () => { + harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); + for (const overrides of [ + { mode: "banner" as const, cardPage: null }, + { mode: "none" as const, cardPage: null }, + { mode: "card" as const, cardPage: "/transactions" }, + ]) { + harness.onboarding = path(ZERO, true, overrides); + const html = render(); + expect(html).not.toContain(CARD); + expect(count(html, HIGHLIGHT)).toBe(0); + expect(html).toContain("Desjardins"); + } + }); +}); + +describe("ImportPage — before the folder is read (#384)", () => { + it("shows neither variant of the empty state, nor the list's empty box, nor the card", () => { + harness.wizard = wizard({ importFolderLoaded: false }); + const html = render(); + expect(html).not.toContain("import.empty.noFolder.title"); + expect(html).not.toContain("import.empty.noSources.title"); + expect(html).not.toContain("import.sources.empty"); + expect(html).not.toContain(CARD); + expect(count(html, HIGHLIGHT)).toBe(0); + // The page itself is there: its heading, the folder card and the history. + expect(html).toContain("t(import.title)"); + expect(html).toContain('data-stub="history"'); + }); + + it("shows the empty state once read", () => { + expect(render()).toContain("t(import.empty.noFolder.title)"); + }); +}); diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 98678a2..c18f69e 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -1,5 +1,9 @@ import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; import { useImportWizard } from "../hooks/useImportWizard"; +import { useOnboarding } from "../contexts/OnboardingContext"; +import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; +import { importCardModel, showsStepCard } from "../utils/onboardingPath"; import ImportFolderConfig from "../components/import/ImportFolderConfig"; import SourceList from "../components/import/SourceList"; import ImportEmptyState, { @@ -46,12 +50,36 @@ export default function ImportPage() { toggleDuplicateRow, setSkipAllDuplicates, } = useImportWizard(); + const onboarding = useOnboarding(); + const { pathname } = useLocation(); const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name; const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0; + // The getting-started card follows the wizard (#398). Its target is + // highlighted only while the card is on screen: a ring needs the card that + // explains it, and the Import page of a finished path shows none. + const cardModel = importCardModel( + { + wizardStep: state.step, + folderLoaded: state.importFolderLoaded, + hasFolder: state.importFolder !== null, + folderName: state.importFolder ? folderNameFromPath(state.importFolder) : null, + sourceCount: state.scannedSources.length, + importing: state.step === "importing", + importedCount: state.step === "report" ? (state.importReport?.importedCount ?? null) : null, + }, + onboarding.checklist, + ); + const target = + cardModel !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname) + ? cardModel.target + : null; + return (

+ +

{t("import.title")}

@@ -104,25 +132,30 @@ export default function ImportPage() { {/* No source to list (#356): the empty state takes the place of the list and tells the user what to do next, which depends on whether a - folder is configured. SourceList itself stays a pure list. + folder is configured. SourceList itself stays a pure list. Neither + shows before the folder is read (#384): the "no folder" variant, or + the list's own empty box, would flash. */} - {showEmptyState ? ( - - ) : ( - - )} + {state.importFolderLoaded && + (showEmptyState ? ( + + ) : ( + + ))} )} @@ -161,6 +194,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.preview")} nextDisabled={nextDisabled || state.isLoading} + onboardingTarget={target === "wizardNext"} />
)} @@ -192,6 +226,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.checkDuplicates")} nextDisabled={state.isLoading} + onboardingTarget={target === "wizardNext"} />
)} @@ -210,6 +245,7 @@ export default function ImportPage() { onNext={() => goToStep("confirm")} onCancel={reset} nextLabel={t("import.wizard.confirm")} + onboardingTarget={target === "wizardNext"} /> )} @@ -230,6 +266,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.import")} showCancel={false} + onboardingTarget={target === "wizardNext"} /> )} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0fad653..38a950a 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1001,3 +1001,23 @@ export interface ChecklistItem { * successful load: a later failed re-read keeps `ready` and the last state. */ export type OnboardingStatus = "loading" | "ready" | "error"; + +/** + * What the Import page's step card reads of the import wizard (#398), built by + * the page from its wizard state; `importCardModel` turns it into the card. + */ +export interface ImportCardInput { + wizardStep: ImportWizardStep; + /** The stored import folder has been read at mount (`importFolderLoaded`): no card before. */ + folderLoaded: boolean; + /** An import folder is configured. */ + hasFolder: boolean; + /** Last segment of the import folder (`folderNameFromPath`), null without a folder. */ + folderName: string | null; + /** Bank sub-folders found by the last scan of the folder. */ + sourceCount: number; + /** An import is writing its rows. */ + importing: boolean; + /** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */ + importedCount: number | null; +} diff --git a/src/styles.contrast.test.ts b/src/styles.contrast.test.ts new file mode 100644 index 0000000..b21369b --- /dev/null +++ b/src/styles.contrast.test.ts @@ -0,0 +1,164 @@ +// The getting-started highlight in styles.css (#398): the ring a step card +// draws around the control to use. Read from the stylesheet itself, so a +// change to a theme color or to the rule is checked where it is made: +// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the +// cards, in the light theme (`:root`) and the dark one (`.dark`); +// - WCAG 2.2.2: the pulse stops by itself after three cycles, within 5 s; +// - the pulse does not run under `prefers-reduced-motion: reduce`. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +const CSS = readFileSync(resolve(import.meta.dirname, "styles.css"), "utf-8"); + +const HIGHLIGHT = '[data-onboarding-highlight="true"]'; + +interface Block { + selector: string; + body: string; +} + +/** The top-level blocks of a stylesheet, comments removed; nested braces stay in the body. */ +function blocks(css: string): Block[] { + const text = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const found: Block[] = []; + let depth = 0; + let start = 0; + let open = -1; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === "{") { + if (depth === 0) open = i; + depth++; + } else if (c === "}") { + depth--; + if (depth === 0) { + found.push({ selector: text.slice(start, open).trim(), body: text.slice(open + 1, i) }); + start = i + 1; + } + } else if (c === ";" && depth === 0) { + start = i + 1; // a statement such as `@import "tailwindcss";` + } + } + return found; +} + +/** The declarations of a block body, `name -> value`, the last one winning. */ +function declarations(body: string): Map { + const result = new Map(); + for (const [, name, value] of body.matchAll(/([\w-]+)\s*:\s*([^;{}]+);/g)) { + result.set(name, value.trim()); + } + return result; +} + +const TOP = blocks(CSS); + +const merged = (selector: string, into = new Map()) => { + for (const block of TOP.filter((b) => b.selector === selector)) { + for (const [name, value] of declarations(block.body)) into.set(name, value); + } + return into; +}; + +const THEME_TOKENS = merged("@theme"); +const LIGHT = merged(":root"); +/** `.dark` overrides `:root`, which still holds what it does not redefine. */ +const DARK = merged(".dark", new Map(LIGHT)); + +/** A color variable resolved through `var()` down to its hex value. */ +function color(name: string, theme: Map): string { + let value = theme.get(name) ?? THEME_TOKENS.get(name); + for (let hops = 0; value !== undefined && hops < 10; hops++) { + const ref = value.match(/^var\((--[\w-]+)\)$/); + if (!ref) break; + value = theme.get(ref[1]) ?? THEME_TOKENS.get(ref[1]); + } + if (value === undefined || !/^#[0-9a-f]{6}$/i.test(value)) { + throw new Error(`${name} does not resolve to a #rrggbb color: ${String(value)}`); + } + return value; +} + +/** WCAG relative luminance of a #rrggbb color. */ +function luminance(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const highlightRule = () => { + const rule = TOP.find((b) => b.selector === HIGHLIGHT); + expect(rule, "the highlight rule").toBeDefined(); + return declarations(rule!.body); +}; + +/** The variable the ring is drawn with: `--accent`, or `--onboarding-ring` if the accent failed. */ +function ringVariable(): string { + const outline = highlightRule().get("outline") ?? ""; + const variable = outline.match(/var\((--[\w-]+)\)/); + expect(variable, `outline: ${outline}`).not.toBeNull(); + return variable![1]; +} + +describe("styles.css — the contrast helpers", () => { + it("compute the WCAG ratios of known pairs", () => { + expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 5); + expect(contrast("#ffffff", "#ffffff")).toBeCloseTo(1, 5); + // #767676 is the classic 4.54:1 grey on white. + expect(contrast("#767676", "#ffffff")).toBeCloseTo(4.54, 2); + }); + + it("read both themes from the stylesheet", () => { + expect(color("--background", LIGHT)).toBe("#FFF8F0"); + expect(color("--card", LIGHT)).toBe("#FFFFFF"); + expect(color("--background", DARK)).toBe("#111827"); + expect(color("--card", DARK)).toBe("#1f2937"); + }); +}); + +describe("styles.css — the getting-started highlight (#398)", () => { + it("draws a 3px outline set off by 2px, in the accent color", () => { + const rule = highlightRule(); + expect(rule.get("outline")).toBe("3px solid var(--accent)"); + expect(rule.get("outline-offset")).toBe("2px"); + expect(ringVariable()).toBe("--accent"); + }); + + it.each([ + ["light", LIGHT], + ["dark", DARK], + ] as const)("reaches 3:1 against the background and the cards, %s theme", (_name, theme) => { + const ring = color(ringVariable(), theme); + for (const surface of ["--background", "--card"]) { + expect(contrast(ring, color(surface, theme)), surface).toBeGreaterThanOrEqual(3); + } + }); + + it("pulses three times at most, within the 5 s of WCAG 2.2.2", () => { + const rule = highlightRule(); + expect(rule.get("animation-name")).toBe("onboarding-pulse"); + expect(rule.get("animation-iteration-count")).toBe("3"); + expect(rule.has("animation")).toBe(false); + const duration = rule.get("animation-duration") ?? ""; + expect(duration).toMatch(/^\d+(\.\d+)?s$/); + expect(parseFloat(duration) * 3).toBeLessThanOrEqual(5); + const keyframes = TOP.find((b) => b.selector === "@keyframes onboarding-pulse"); + expect(keyframes?.body).toContain("box-shadow"); + }); + + it("does not pulse when the system asks for less motion", () => { + const media = TOP.find((b) => b.selector === "@media (prefers-reduced-motion: reduce)"); + expect(media, "the reduced-motion block").toBeDefined(); + const inner = blocks(media!.body).find((b) => b.selector === HIGHLIGHT); + expect(inner, "the highlight rule under reduced motion").toBeDefined(); + expect(declarations(inner!.body).get("animation")).toBe("none"); + }); +}); diff --git a/src/styles.css b/src/styles.css index 561610b..48250c7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -148,6 +148,37 @@ body { display: none; } +/* + * Getting-started path (#398): the control a step card points at. An outline + * in the accent color, distinct from the primary focus ring and at least 3:1 + * against the page and the cards in both themes (styles.contrast.test.ts), + * plus a pulse that stops after three cycles (3.6 s, under the 5 s of WCAG + * 2.2.2) and does not run at all when the system asks for less motion. + */ +[data-onboarding-highlight="true"] { + outline: 3px solid var(--accent); + outline-offset: 2px; + animation-name: onboarding-pulse; + animation-duration: 1.2s; + animation-timing-function: ease-out; + animation-iteration-count: 3; +} + +@keyframes onboarding-pulse { + from { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); + } + to { + box-shadow: 0 0 0 10px transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-onboarding-highlight="true"] { + animation: none; + } +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; diff --git a/src/utils/onboardingPath.test.ts b/src/utils/onboardingPath.test.ts index bbbc0af..510b051 100644 --- a/src/utils/onboardingPath.test.ts +++ b/src/utils/onboardingPath.test.ts @@ -3,17 +3,24 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist"; import { CARD_PAGES, bannerMode, + cardNext, + cardStep, + importCardModel, latchCardPage, nextStep, + onboardingTargetAttributes, readOnboardingNavState, + showsStepCard, stepNavState, stepsOfPage, type BannerModeInput, + type ImportCardModel, } from "./onboardingPath"; import { CHECKLIST_ITEM_KEYS, type ChecklistItem, type ChecklistItemKey, + type ImportCardInput, type OnboardingCounters, } from "../shared/types"; @@ -334,3 +341,241 @@ describe("stepNavState (#397)", () => { expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false }); }); }); + +describe("CARD_PAGES (#398)", () => { + it("carries the Import page", () => { + expect(CARD_PAGES.has("/import")).toBe(true); + }); +}); + +describe("showsStepCard (#398)", () => { + it("is true in card mode on the latched route only", () => { + expect(showsStepCard("card", "/import", "/import")).toBe(true); + expect(showsStepCard("card", "/import", "/transactions")).toBe(false); + expect(showsStepCard("card", null, "/import")).toBe(false); + for (const mode of ["none", "banner", "complete"] as const) { + expect(showsStepCard(mode, "/import", "/import"), mode).toBe(false); + } + }); +}); + +describe("cardStep (#398)", () => { + const at = (checklist: Checklist, pathname = "/import") => { + const found = cardStep(checklist, pathname); + return found && { key: found.step.key, n: found.n, total: found.total }; + }; + + it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => { + expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 }); + expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 }); + expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({ + key: "import", + n: 4, + total: 6, + }); + }); + + it("shows the import step once the three are done", () => { + expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({ + key: "import", + n: 4, + total: 6, + }); + }); + + it("passes over a skipped step, as the next step does", () => { + // After "Skip" on the folder step, the card latches again on /import for + // the source step: it must show that step, not the skipped one. + expect(at(checklistOf(zero, { skipped: ["importFolder"] }))?.key).toBe("source"); + expect(at(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import"); + expect(at(checklistOf(zero, { skipped: ["importFolder", "source", "import"] }))?.key).toBe( + "import", + ); + }); + + it("counts the unlocked rows only: five in the Free edition", () => { + expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({ + key: "importFolder", + n: 2, + total: 5, + }); + expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({ + key: "manualCategory", + n: 5, + total: 6, + }); + }); + + it("is null on a page that guides no unlocked step", () => { + expect(cardStep(checklistOf(zero), "/reports")).toBeNull(); + expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull(); + expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget"); + }); +}); + +describe("cardNext (#398)", () => { + it("links the next step when it lives on another page", () => { + expect(cardNext(step("manualCategory"), "/import", true)).toEqual({ + kind: "step", + step: step("manualCategory"), + }); + // The link does not wait for the entitlement: only the end does. + expect(cardNext(step("manualCategory"), "/import", false).kind).toBe("step"); + }); + + it("is the end without a next step, once the Budget entitlement is known", () => { + expect(cardNext(null, "/import", true)).toEqual({ kind: "end" }); + expect(cardNext(null, "/import", false)).toEqual({ kind: "wait" }); + }); + + it("waits while the next step is still one of this page's own (re-read not landed)", () => { + for (const key of ["importFolder", "source", "import"] as const) { + expect(cardNext(step(key), "/import", true), key).toEqual({ kind: "wait" }); + } + expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" }); + expect(cardNext(step("budget"), "/import", true).kind).toBe("step"); + }); +}); + +describe("importCardModel (#398)", () => { + /** A new profile: folder read, none chosen, nothing scanned. */ + const NEW: ImportCardInput = { + wizardStep: "source-list", + folderLoaded: true, + hasFolder: false, + folderName: null, + sourceCount: 0, + importing: false, + importedCount: null, + }; + /** A folder with two bank sub-folders, the wizard at rest. */ + const AT_REST: ImportCardInput = { ...NEW, hasFolder: true, folderName: "Relevés", sourceCount: 2 }; + const fresh = checklistOf(zero, { importFolderSet: true }); + /** Steps 3 and 4 checked: a source and a completed `imported_files` row. */ + const imported = checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true }); + + const model = (input: Partial, checklist: Checklist | null = fresh) => + importCardModel({ ...AT_REST, ...input }, checklist); + const wizardNext = (text: string, button: string): ImportCardModel => ({ + text: { key: `onboarding.card.import.${text}`, labels: { button } }, + done: false, + target: "wizardNext", + }); + const DONE: ImportCardModel = { text: null, done: true, target: null }; + const IMPORTING: ImportCardModel = { + text: { key: "onboarding.card.import.importing" }, + done: false, + target: null, + }; + const FAILED: ImportCardModel = { + text: { key: "onboarding.card.import.failed" }, + done: false, + target: null, + }; + + it("1. no card before the folder is read, whatever else is true", () => { + expect(importCardModel({ ...NEW, folderLoaded: false }, fresh)).toBeNull(); + expect(model({ folderLoaded: false, importing: true, wizardStep: "importing" })).toBeNull(); + }); + + it("2. an import running says so, before the counters that already tick", () => { + expect(model({ wizardStep: "importing", importing: true })).toEqual(IMPORTING); + // The `imported_files` row is written `completed` before the inserts: + // steps 3 and 4 read as checked while the import runs. + expect(model({ wizardStep: "importing", importing: true }, imported)).toEqual(IMPORTING); + // The wizard's own step says it too. + expect(model({ wizardStep: "importing", importing: false }, imported)).toEqual(IMPORTING); + }); + + it("3. a report of an import that added nothing is a failure, never 'Done.'", () => { + expect(model({ wizardStep: "report", importedCount: 0 })).toEqual(FAILED); + // Duplicates only: the file row is `completed`, so the checklist counts + // the import step as done; the card still does not say "Done.". + expect(model({ wizardStep: "report", importedCount: 0 }, imported)).toEqual(FAILED); + // No count at all is no success either. + expect(model({ wizardStep: "report", importedCount: null }, imported)).toEqual(FAILED); + }); + + it("4. a report of an import that added transactions is 'Done.', even before the re-read", () => { + expect(model({ wizardStep: "report", importedCount: 41 }, imported)).toEqual(DONE); + expect(model({ wizardStep: "report", importedCount: 1 }, fresh)).toEqual(DONE); + }); + + it("5. without a folder: choose one, with the empty state's button", () => { + const noFolder: ImportCardModel = { + text: { + key: "onboarding.card.import.noFolder", + labels: { button: "import.empty.noFolder.cta" }, + }, + done: false, + target: "emptyStateAction", + }; + expect(importCardModel(NEW, checklistOf(zero))).toEqual(noFolder); + // Before "steps checked": the folder step is the one left. + expect(importCardModel(NEW, imported)).toEqual(noFolder); + }); + + it("6. a folder without bank sub-folder: create them, or the sample folder", () => { + expect(model({ sourceCount: 0 })).toEqual({ + text: { + key: "onboarding.card.import.noSources", + labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" }, + values: { folder: "Relevés" }, + }, + done: false, + target: "emptyStateAction", + }); + }); + + it("7. the wizard: the first source card, then its next button with the label it shows", () => { + expect(model({ wizardStep: "source-list" })).toEqual({ + text: { key: "onboarding.card.import.sourceList" }, + done: false, + target: "firstSource", + }); + expect(model({ wizardStep: "source-config" })).toEqual( + wizardNext("sourceConfig", "import.wizard.preview"), + ); + expect(model({ wizardStep: "file-preview" })).toEqual( + wizardNext("filePreview", "import.wizard.checkDuplicates"), + ); + expect(model({ wizardStep: "duplicate-check" })).toEqual( + wizardNext("duplicateCheck", "import.wizard.confirm"), + ); + expect(model({ wizardStep: "confirm" })).toEqual(wizardNext("confirm", "import.wizard.import")); + }); + + it("8. steps 3 and 4 checked, the wizard at rest: 'Done.' (after 'Done' or 'Cancel')", () => { + expect(model({ wizardStep: "source-list" }, imported)).toEqual(DONE); + // Ahead of the rows of the wizard at rest: an emptied folder too. + expect(model({ wizardStep: "source-list", sourceCount: 0 }, imported)).toEqual(DONE); + }); + + it("8. ...but only at rest: a new pass through the wizard is guided again", () => { + expect(model({ wizardStep: "source-config" }, imported)).toEqual( + wizardNext("sourceConfig", "import.wizard.preview"), + ); + expect(model({ wizardStep: "confirm" }, imported)).toEqual( + wizardNext("confirm", "import.wizard.import"), + ); + }); + + it("8. ...and only when both are checked, by the data: skipped is not checked", () => { + const sourceOnly = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }); + expect(model({}, sourceOnly)?.target).toBe("firstSource"); + const skipped = checklistOf(zero, { importFolderSet: true, skipped: ["source", "import"] }); + expect(model({}, skipped)?.target).toBe("firstSource"); + expect(model({}, null)?.target).toBe("firstSource"); + }); +}); + +describe("onboardingTargetAttributes (#398)", () => { + it("marks the target and its highlight when on, nothing otherwise", () => { + expect(onboardingTargetAttributes("wizard-next", true)).toEqual({ + "data-onboarding-target": "wizard-next", + "data-onboarding-highlight": "true", + }); + expect(onboardingTargetAttributes("wizard-next", false)).toEqual({}); + expect(onboardingTargetAttributes("wizard-next", undefined)).toEqual({}); + }); +}); diff --git a/src/utils/onboardingPath.ts b/src/utils/onboardingPath.ts index f65d282..2984ce6 100644 --- a/src/utils/onboardingPath.ts +++ b/src/utils/onboardingPath.ts @@ -1,12 +1,17 @@ import type { Checklist } from "./onboardingChecklist"; -import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types"; +import type { + ChecklistItem, + ChecklistItemKey, + ImportCardInput, + OnboardingStatus, +} from "../shared/types"; /** - * Pure rules of the guided getting-started path (#396). + * Pure rules of the guided getting-started path (#396, #398). * - * The provider (`useOnboardingChecklist`) feeds them its state; the banner and - * the step cards of the next issues only read what they decide. Nothing here - * touches React, the router or the database. + * 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. */ /** @@ -18,11 +23,11 @@ export function nextStep(checklist: Checklist): ChecklistItem | null { } /** - * Routes whose page carries a step card. Empty in this issue: each card issue - * adds its page, and a page without a delivered card keeps the banner, so a - * partly merged stack never removes any guidance. + * Routes whose page carries a step card. Each card issue adds its page (#398: + * Import), and a page without a delivered card keeps the banner, so a partly + * merged stack never removes any guidance. */ -export const CARD_PAGES: ReadonlySet = new Set(); +export const CARD_PAGES: ReadonlySet = new Set(["/import"]); const PAGE_STEPS: Readonly> = { "/import": ["importFolder", "source", "import"], @@ -145,3 +150,203 @@ export function readOnboardingNavState(state: unknown): OnboardingNavState { 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>; + /** Interpolations shown as they are (a folder name). */ + values?: Readonly>; +} + +/** 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 { + 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 `executeImport` writes the `imported_files` row + * `completed` before inserting a single transaction; + * 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 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" } : {}; +}