import { useCallback, useEffect, useMemo, useReducer } from "react"; import { getOnboardingCounters, hideChecklist, showChecklist, type OnboardingSnapshot, } from "../services/onboardingService"; import { logError } from "../services/logService"; import { computeChecklist, type Checklist } from "../utils/onboardingChecklist"; import { useEntitlement } from "./useEntitlement"; import type { OnboardingCounters } from "../shared/types"; // State of the getting-started checklist (#351). The reducer and its initial // state are exported so the state machine is tested without renderHook (same // pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`). export type OnboardingChecklistStatus = "loading" | "ready" | "error"; export interface OnboardingChecklistState { status: OnboardingChecklistStatus; /** Null until the first successful load. */ counters: OnboardingCounters | null; /** True when an import folder has been chosen (read with the counters). */ importFolderSet: boolean; /** Dismissed by the user. Untouched by a load error. */ hidden: boolean; /** Message of the last load error, null otherwise. */ error: string | null; } export type OnboardingChecklistAction = | { type: "LOAD_START" } | { type: "LOAD_DONE"; snapshot: OnboardingSnapshot } | { type: "LOAD_ERROR"; error: string } | { type: "HIDE" } | { type: "SHOW" }; export const initialOnboardingChecklistState: OnboardingChecklistState = { status: "loading", counters: null, importFolderSet: false, hidden: false, error: null, }; export function onboardingChecklistReducer( state: OnboardingChecklistState, action: OnboardingChecklistAction, ): OnboardingChecklistState { switch (action.type) { case "LOAD_START": // The previous counters stay so a refresh does not blank the list. return { ...state, status: "loading", error: null }; case "LOAD_DONE": return { ...state, status: "ready", counters: action.snapshot.counters, importFolderSet: action.snapshot.importFolderSet, hidden: action.snapshot.hidden, error: null, }; case "LOAD_ERROR": // `hidden` is deliberately kept: a failed read must not resurface a // checklist the user dismissed. return { ...state, status: "error", error: action.error }; case "HIDE": return { ...state, hidden: true }; case "SHOW": return { ...state, hidden: false }; } } function errorMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } /** * Getting-started checklist of the dashboard: counters read from the profile * database, dismissal preference, and the Budget entitlement that decides * whether the sixth row is locked. */ export function useOnboardingChecklist() { const [state, dispatch] = useReducer( onboardingChecklistReducer, initialOnboardingChecklistState, ); const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget"); const refresh = useCallback(async () => { dispatch({ type: "LOAD_START" }); try { const snapshot = await getOnboardingCounters(); dispatch({ type: "LOAD_DONE", snapshot }); } catch (e) { const error = errorMessage(e); logError("Onboarding: failed to load checklist counters", error); dispatch({ type: "LOAD_ERROR", error }); } }, []); // Read-only local SELECTs: a StrictMode double run is harmless. useEffect(() => { 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 checklist: Checklist | null = useMemo( () => state.counters ? computeChecklist(state.counters, { importFolderSet: state.importFolderSet, budgetAllowed, }) : null, [state.counters, state.importFolderSet, budgetAllowed], ); return { status: state.status, checklist, /** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */ counters: state.counters, hidden: state.hidden, /** Message of the last load error, null otherwise. */ error: state.error, budgetReady, refresh, hide, show, }; }