Simpl-Resultat/src/hooks/useOnboardingChecklist.ts
le king fu dcf3b013dc feat(onboarding): share the getting-started path through a provider
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>
2026-10-08 07:33:57 -04:00

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,
],
);
}