The checklist state moves from DashboardPage to OnboardingProvider, mounted in AppShell around the routed page; pages read it through useOnboarding(). The reducer stays in useOnboardingChecklist: - loads are numbered and a stale answer is ignored; - HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started before them (#384); - a refresh keeps the counters and the ready status, and a failed re-read after a first success keeps the last state; only the start of a failure streak is logged; - re-read at mount, on every route change and 150 ms after a burst of data events. onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and is a pure read (skipped steps included, one preference per step, filtered on the six keys and the value "1"); initializeGuidedPath applies rule D9 once per profile in one transaction (hidden first, then the flag, ROLLBACK on failure), awaited once by the first load through a promise shared by StrictMode's double mount; skipStep / resumeStep are idempotent. computeChecklist counts skipped rows; the new pure onboardingPath module holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch, bannerMode and readOnboardingNavState. The dashboard reads the context and keeps its compact bar for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
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<OnboardingSnapshot>;
|
|
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<void> {
|
|
let seq = 0;
|
|
let initialization: Promise<void> | null = null;
|
|
let failing = false;
|
|
|
|
const initializeOnce = (): Promise<void> => {
|
|
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<typeof setTimeout> | 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<string>;
|
|
}
|
|
|
|
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<void>;
|
|
show: () => Promise<void>;
|
|
skip: (key: ChecklistItemKey) => Promise<void>;
|
|
resume: (key: ChecklistItemKey) => Promise<void>;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
],
|
|
);
|
|
}
|