From ed5eeb0265abe9374b1ff7af15049b3c3f13f0d8 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:34:45 -0400 Subject: [PATCH] feat(onboarding): step card with highlight, and the Import page's card On the page of the step in progress, a card takes the place of the banner. `StepCard` is driven by props: an h2 title that takes the focus ("Step n of N", then the step), the page's sentence, a status region always rendered that receives "Done.", "Skip this step" until the step is done, then "Next step" or the end of the path. `OnboardingStepCard` is the only piece that reads the path: it renders on the latched card page only, so the card survives its own success, focuses its title once per arrival by "Next step" and consumes the navigation state. The control to use carries `data-onboarding-highlight`, drawn in styles.css as a 3px accent outline with a pulse bounded to three cycles and none under prefers-reduced-motion; a test reads the stylesheet and checks 3:1 against the background and the cards in both themes. The target is passed by props to EmptyState's primary action, the first SourceList card and WizardNavigation's next button, never queried. `importCardModel` reads the wizard and the checklist in order: importing before the counters, a report that added nothing as a failure, and the "steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets /import; the Import page builds the input, renders the card first and highlights the model's target only while the card shows, and its empty state waits for the folder to be read. Co-Authored-By: Claude Opus 5.5 --- .../import/ImportEmptyState.test.tsx | 26 ++ src/components/import/ImportEmptyState.tsx | 9 +- src/components/import/SourceCard.tsx | 8 + src/components/import/SourceList.test.tsx | 63 ++++ src/components/import/SourceList.tsx | 6 +- .../import/WizardNavigation.test.tsx | 55 ++++ src/components/import/WizardNavigation.tsx | 8 + .../layout/AppShell.render.test.tsx | 14 +- .../onboarding/OnboardingStepCard.test.tsx | 236 +++++++++++++++ .../onboarding/OnboardingStepCard.tsx | 71 +++++ .../onboarding/StepCard.render.test.tsx | 212 +++++++++++++ src/components/onboarding/StepCard.tsx | 136 +++++++++ src/components/shared/EmptyState.test.tsx | 42 ++- src/components/shared/EmptyState.tsx | 13 +- src/i18n/locales/en.json | 17 ++ src/i18n/locales/fr.json | 17 ++ src/pages/ImportPage.render.test.tsx | 283 ++++++++++++++++++ src/pages/ImportPage.tsx | 73 +++-- src/shared/types/index.ts | 20 ++ src/styles.contrast.test.ts | 164 ++++++++++ src/styles.css | 31 ++ src/utils/onboardingPath.test.ts | 245 +++++++++++++++ src/utils/onboardingPath.ts | 223 +++++++++++++- 23 files changed, 1936 insertions(+), 36 deletions(-) create mode 100644 src/components/import/SourceList.test.tsx create mode 100644 src/components/import/WizardNavigation.test.tsx create mode 100644 src/components/onboarding/OnboardingStepCard.test.tsx create mode 100644 src/components/onboarding/OnboardingStepCard.tsx create mode 100644 src/components/onboarding/StepCard.render.test.tsx create mode 100644 src/components/onboarding/StepCard.tsx create mode 100644 src/pages/ImportPage.render.test.tsx create mode 100644 src/styles.contrast.test.ts 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" } : {}; +}