The getting-started path now follows the user: AppShell mounts OnboardingBanner at the top of <main>, in the page flow, and the provider's mode keeps it off a hidden path, the dashboard of a profile without transactions, a page that carries a step card and the first load. The banner shows "Getting started n/N" once the Budget entitlement is known, the next step linked with its navigation state, "Show the list", which unfolds the detailed list in place, and "Hide"; at the end, the closing message, "Open the guide" and "Close". Each route mounts its own banner slot, so a page always starts folded. The dashboard's compact bar and the compact mode of OnboardingChecklist are gone (D7). The detailed list reads each state out to screen readers, shows a skipped step with its badge and "Resume", and gives every link to a step the same navigation state (stepNavState), with the guided entry for the manual category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
6 KiB
TypeScript
147 lines
6 KiB
TypeScript
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<string> = new Set<string>();
|
|
|
|
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
|
"/import": ["importFolder", "source", "import"],
|
|
"/transactions": ["manualCategory"],
|
|
"/budget": ["budget"],
|
|
};
|
|
|
|
/** The steps a page guides, in checklist order (none for any other page). */
|
|
export function stepsOfPage(pathname: string): readonly ChecklistItemKey[] {
|
|
return Object.prototype.hasOwnProperty.call(PAGE_STEPS, pathname) ? PAGE_STEPS[pathname] : [];
|
|
}
|
|
|
|
export interface CardLatchInput {
|
|
pathname: string;
|
|
nextStep: ChecklistItem | null;
|
|
hidden: boolean;
|
|
/** Pages that carry a card. Defaults to `CARD_PAGES`; tests pass their own. */
|
|
cardPages?: ReadonlySet<string>;
|
|
}
|
|
|
|
/**
|
|
* The card latch: the page that carries a step card, or null.
|
|
*
|
|
* - It is set when `pathname` is the route of the next step and is in the
|
|
* card pages.
|
|
* - It is released when the route changes, when the path is hidden, and after
|
|
* "Skip" when the next step is no longer on this page: the skip action clears
|
|
* the latch (`previous` = null), and this function sets it again only if the
|
|
* new next step is on the same page.
|
|
* - A step that gets done does not release it: the card stays to say "Done."
|
|
* and offer the next step, even when that step lives on another page.
|
|
*
|
|
* Idempotent: applying it to its own result changes nothing, which lets the
|
|
* provider re-run it on every render.
|
|
*/
|
|
export function latchCardPage(previous: string | null, input: CardLatchInput): string | null {
|
|
const { pathname, nextStep: next, hidden } = input;
|
|
const cardPages = input.cardPages ?? CARD_PAGES;
|
|
if (hidden) return null;
|
|
if (previous !== null && previous === pathname) return previous;
|
|
if (next !== null && next.to === pathname && cardPages.has(pathname)) return pathname;
|
|
return null;
|
|
}
|
|
|
|
export type BannerMode = "none" | "banner" | "card" | "complete";
|
|
|
|
export interface BannerModeInput {
|
|
status: OnboardingStatus;
|
|
checklist: Checklist | null;
|
|
hidden: boolean;
|
|
pathname: string;
|
|
/** The dashboard of a profile without any transaction (D11). */
|
|
isEmptyDashboard: boolean;
|
|
/** False until the Budget entitlement is known. */
|
|
budgetReady: boolean;
|
|
/** The latched card page (`latchCardPage`). */
|
|
cardPage: string | null;
|
|
}
|
|
|
|
/**
|
|
* What the path shows on the current page:
|
|
* - `none` before the first successful load, when the path is hidden (D8, D9),
|
|
* and on the empty dashboard, whose welcome card already holds the list (D11);
|
|
* - `card` on the latched card page, even once every step is done (the card
|
|
* then says so itself);
|
|
* - `complete` when every unlocked step is done or skipped (D4), and only once
|
|
* Budget's entitlement is known: until then the checklist is computed with
|
|
* Budget locked, and "complete" could be a sixth step away;
|
|
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
|
|
*
|
|
* A failed re-read after a first success keeps status `ready` (reducer), so
|
|
* the last state stays on screen.
|
|
*/
|
|
export function bannerMode(input: BannerModeInput): BannerMode {
|
|
const { status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage } = input;
|
|
if (status !== "ready" || checklist === null) return "none";
|
|
if (hidden) return "none";
|
|
if (isEmptyDashboard) return "none";
|
|
if (cardPage !== null && cardPage === pathname) return "card";
|
|
if (checklist.complete) return budgetReady ? "complete" : "none";
|
|
return "banner";
|
|
}
|
|
|
|
/** What a page may read from `location.state` about the path. */
|
|
export interface OnboardingNavState {
|
|
/** Move the focus to the step card's title. */
|
|
focus: boolean;
|
|
/** Open the page in its guided entry (Transactions: uncategorized only). */
|
|
guided: boolean;
|
|
}
|
|
|
|
/** What a link to a step puts in `location.state`, read back by `readOnboardingNavState`. */
|
|
export interface OnboardingLinkState {
|
|
onboarding: { focus: true; guided?: true };
|
|
}
|
|
|
|
/**
|
|
* The navigation state of every link to a step's page (#397): the banner's
|
|
* next step, the rows of the list and their "Resume". The step card of the
|
|
* page takes the focus, and the "categorize by hand" step also opens
|
|
* Transactions in its guided entry, wherever its link was clicked.
|
|
*/
|
|
export function stepNavState(key: ChecklistItemKey): OnboardingLinkState {
|
|
return key === "manualCategory"
|
|
? { onboarding: { focus: true, guided: true } }
|
|
: { onboarding: { focus: true } };
|
|
}
|
|
|
|
/**
|
|
* The only way to read `location.state` (typed `any` by react-router) for the
|
|
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
|
|
* `{ onboarding: { guided: true } }` reads as false, whatever its shape.
|
|
*/
|
|
export function readOnboardingNavState(state: unknown): OnboardingNavState {
|
|
if (typeof state !== "object" || state === null) return { focus: false, guided: false };
|
|
const onboarding: unknown = (state as { onboarding?: unknown }).onboarding;
|
|
if (typeof onboarding !== "object" || onboarding === null) {
|
|
return { focus: false, guided: false };
|
|
}
|
|
const flags = onboarding as { focus?: unknown; guided?: unknown };
|
|
return { focus: flags.focus === true, guided: flags.guided === true };
|
|
}
|