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