Simpl-Resultat/src/utils/onboardingPath.ts
le king fu 75c1708bbe feat(onboarding): show the getting-started banner on top of every page
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>
2026-10-08 08:02:39 -04:00

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 };
}