import type { Checklist } from "./onboardingChecklist"; import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types"; /** * Pure rules of the guided getting-started path (#396). * * 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 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. 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. */ export const CARD_PAGES: ReadonlySet = new Set(); 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 }; }