import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { useLocation } from "react-router-dom"; import { getOnboardingSnapshot, hideChecklist, initializeGuidedPath, resumeStep, showChecklist, skipStep, type OnboardingSnapshot, } from "../services/onboardingService"; import { subscribeDataChanged } from "../services/dataEvents"; import { logError } from "../services/logService"; import { computeChecklist, type Checklist } from "../utils/onboardingChecklist"; import { bannerMode, latchCardPage, nextStep as findNextStep, type BannerMode, } from "../utils/onboardingPath"; import { useEntitlement } from "./useEntitlement"; import { CHECKLIST_ITEM_KEYS, type ChecklistItem, type ChecklistItemKey, type OnboardingCounters, type OnboardingStatus, } from "../shared/types"; // State of the getting-started path (#351, #396). One instance lives in // `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the // loader and the data-channel follower are exported so the state machine is // tested without renderHook (same pattern as `licenseReducer`, // `performSubmitKey`, `revocationCheckLauncher`). export interface OnboardingChecklistState { /** `error` only while no load ever succeeded; a failed re-read keeps `ready`. */ status: OnboardingStatus; /** Null until the first successful load. */ counters: OnboardingCounters | null; /** True when an import folder has been chosen (read with the counters). */ importFolderSet: boolean; /** Hidden by the user or by rule D9. Untouched by a load error. */ hidden: boolean; /** Skipped steps, in checklist order. */ skipped: ChecklistItemKey[]; /** Number of the last load started: the outcome of any other load is stale. */ loadSeq: number; /** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */ hiddenSetAt: number; /** Same, for SKIP / RESUME and the skipped steps. */ skippedSetAt: number; /** The page that carries a step card (`latchCardPage`), null otherwise. */ cardPage: string | null; } export type OnboardingChecklistAction = | { type: "LOAD_START"; seq: number } | { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot } | { type: "LOAD_ERROR"; seq: number } | { type: "HIDE" } | { type: "SHOW" } | { type: "SKIP"; key: ChecklistItemKey } | { type: "RESUME"; key: ChecklistItemKey } | { type: "SET_CARD_PAGE"; cardPage: string | null }; export const initialOnboardingChecklistState: OnboardingChecklistState = { status: "loading", counters: null, importFolderSet: false, hidden: false, skipped: [], loadSeq: 0, hiddenSetAt: 0, skippedSetAt: 0, cardPage: null, }; /** `keys` plus `key`, kept in checklist order and without duplicates. */ function withStep(keys: readonly ChecklistItemKey[], key: ChecklistItemKey): ChecklistItemKey[] { return CHECKLIST_ITEM_KEYS.filter((k) => k === key || keys.includes(k)); } export function onboardingChecklistReducer( state: OnboardingChecklistState, action: OnboardingChecklistAction, ): OnboardingChecklistState { switch (action.type) { case "LOAD_START": // A re-read keeps what is on screen, `ready` included: only a path that // never loaded shows `loading`. return { ...state, loadSeq: action.seq, status: state.status === "ready" ? "ready" : "loading", }; case "LOAD_DONE": { if (action.seq !== state.loadSeq) return state; const { snapshot } = action; return { ...state, status: "ready", counters: snapshot.counters, importFolderSet: snapshot.importFolderSet, // A local action dispatched after this load started wins over its // snapshot, read before the action's write (#384). hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden, skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped, }; } case "LOAD_ERROR": if (action.seq !== state.loadSeq) return state; // After a first success the last state stays on screen; `hidden` is never // touched, so a failed read cannot resurface a path the user dismissed. return { ...state, status: state.status === "ready" ? "ready" : "error" }; case "HIDE": return { ...state, hidden: true, hiddenSetAt: state.loadSeq }; case "SHOW": return { ...state, hidden: false, hiddenSetAt: state.loadSeq }; case "SKIP": return { ...state, skipped: withStep(state.skipped, action.key), skippedSetAt: state.loadSeq, // "Skip" releases the card latch; the provider sets it again on the // next render if the new next step is on the same page. cardPage: null, }; case "RESUME": return { ...state, skipped: state.skipped.filter((key) => key !== action.key), skippedSetAt: state.loadSeq, }; case "SET_CARD_PAGE": return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage }; } } function errorMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } export interface OnboardingLoaderDeps { initializeGuidedPath: () => Promise<{ hidden: boolean }>; getOnboardingSnapshot: () => Promise; dispatch: (action: OnboardingChecklistAction) => void; logError: (...args: unknown[]) => void; } /** * The provider's load function (#396), kept outside React so it is tested * without a renderer: * - every load is numbered, and the reducer drops the outcome of any load but * the last one started (a stale answer never overwrites a newer one); * - the first load waits for rule D9 (`initializeGuidedPath`), run once per * loader: the promise is shared, so the double mount of StrictMode does not * run it twice. A failed initialization is logged and the load goes on, the * path showing as stored; the flag stays unset, so the next opening retries; * - a failed load is logged only when it starts a failure streak: repeated * re-reads must not push a real cause out of the last log lines that a * feedback report carries. */ export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promise { let seq = 0; let initialization: Promise | null = null; let failing = false; const initializeOnce = (): Promise => { if (initialization === null) { initialization = deps.initializeGuidedPath().then( () => undefined, (e: unknown) => { deps.logError( "Onboarding: failed to apply the first-run rule, the path shows as stored", errorMessage(e), ); }, ); } return initialization; }; return async () => { const mine = ++seq; deps.dispatch({ type: "LOAD_START", seq: mine }); await initializeOnce(); try { const snapshot = await deps.getOnboardingSnapshot(); if (mine === seq) failing = false; deps.dispatch({ type: "LOAD_DONE", seq: mine, snapshot }); } catch (e) { if (mine === seq && !failing) { failing = true; deps.logError("Onboarding: failed to load the getting-started path", errorMessage(e)); } deps.dispatch({ type: "LOAD_ERROR", seq: mine }); } }; } /** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */ export const DATA_CHANGE_DELAY_MS = 150; /** * Follows the data channel: `run` is called once `delayMs` passed without a * new event. Returns the cleanup, which unsubscribes and drops a pending run. */ export function followDataChanges( run: () => void, delayMs: number = DATA_CHANGE_DELAY_MS, ): () => void { let timer: ReturnType | null = null; const unsubscribe = subscribeDataChanged(() => { if (timer !== null) clearTimeout(timer); timer = setTimeout(() => { timer = null; run(); }, delayMs); }); return () => { unsubscribe(); if (timer !== null) { clearTimeout(timer); timer = null; } }; } export interface OnboardingViewInput { state: OnboardingChecklistState; checklist: Checklist | null; pathname: string; budgetReady: boolean; /** Pages that carry a card: `CARD_PAGES` when omitted, tests pass their own. */ cardPages?: ReadonlySet; } export interface OnboardingView { nextStep: ChecklistItem | null; /** The card latch for this render; the provider stores it when it changed. */ cardPage: string | null; mode: BannerMode; } /** * What one render of the provider shows, from its state: the next step, the * card latch and the mode. Pure, so the provider's render logic is tested * without React. */ export function deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages, }: OnboardingViewInput): OnboardingView { const nextStep = checklist ? findNextStep(checklist) : null; const cardPage = latchCardPage(state.cardPage, { pathname, nextStep, hidden: state.hidden, cardPages, }); const mode = bannerMode({ status: state.status, checklist, hidden: state.hidden, pathname, isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0, budgetReady, cardPage, }); return { nextStep, cardPage, mode }; } /** What the provider shares through `useOnboarding()`. */ export interface OnboardingValue { status: OnboardingStatus; checklist: Checklist | null; /** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */ counters: OnboardingCounters | null; hidden: boolean; budgetReady: boolean; /** First unlocked step neither done nor skipped; its link is `nextStep.to`. */ nextStep: ChecklistItem | null; /** What the current page shows of the path (`bannerMode`). */ mode: BannerMode; /** The page that carries a step card, null otherwise. */ cardPage: string | null; hide: () => Promise; show: () => Promise; skip: (key: ChecklistItemKey) => Promise; resume: (key: ChecklistItemKey) => Promise; refresh: () => Promise; } /** * Getting-started path: counters read from the profile database, hidden and * skipped preferences, the Budget entitlement that decides whether the sixth * row is locked, and what each page shows of it. Called once, by * `OnboardingProvider`; pages read it through `useOnboarding()`. * * Re-read at mount, at every route change and 150 ms after a burst of data * events (ADR 0022). The provider is the only writer of the onboarding * preferences: its actions update the state themselves, without a re-read. */ export function useOnboardingChecklist(): OnboardingValue { const [state, dispatch] = useReducer( onboardingChecklistReducer, initialOnboardingChecklistState, ); const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget"); const { pathname } = useLocation(); // One loader per provider instance: a profile switch remounts AppShell, so // the new profile gets its own numbering and its own D9 initialization. const [refresh] = useState(() => createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }), ); // Read at mount, then again at every route change. useEffect(() => { void refresh(); }, [refresh, pathname]); // Re-read after the writes that move a counter, once a burst is over. useEffect(() => followDataChanges(() => void refresh()), [refresh]); const hide = useCallback(async () => { try { await hideChecklist(); dispatch({ type: "HIDE" }); } catch (e) { logError("Onboarding: failed to hide checklist", errorMessage(e)); } }, []); const show = useCallback(async () => { try { await showChecklist(); dispatch({ type: "SHOW" }); } catch (e) { logError("Onboarding: failed to show checklist", errorMessage(e)); } }, []); const skip = useCallback(async (key: ChecklistItemKey) => { try { await skipStep(key); dispatch({ type: "SKIP", key }); } catch (e) { logError("Onboarding: failed to skip a step", errorMessage(e)); } }, []); const resume = useCallback(async (key: ChecklistItemKey) => { try { await resumeStep(key); dispatch({ type: "RESUME", key }); } catch (e) { logError("Onboarding: failed to resume a step", errorMessage(e)); } }, []); const checklist: Checklist | null = useMemo( () => state.counters ? computeChecklist(state.counters, { importFolderSet: state.importFolderSet, budgetAllowed, skipped: state.skipped, }) : null, [state.counters, state.importFolderSet, state.skipped, budgetAllowed], ); const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady, }); // The card latch is derived during render, then stored: an effect would // leave one frame showing the banner on a page that carries a card. The // update is for this component's own reducer, which React applies before // committing; `latchCardPage` is idempotent, so it settles in one pass. if (cardPage !== state.cardPage) { dispatch({ type: "SET_CARD_PAGE", cardPage }); } return useMemo( () => ({ status: state.status, checklist, counters: state.counters, hidden: state.hidden, budgetReady, nextStep, mode, cardPage, hide, show, skip, resume, refresh, }), [ state.status, checklist, state.counters, state.hidden, budgetReady, nextStep, mode, cardPage, hide, show, skip, resume, refresh, ], ); }