Simpl-Resultat/src/hooks/useOnboardingChecklist.ts
le king fu a060f0e0e0
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352)
A profile without any transaction now opens a dashboard that says what to
do: a welcome card with the expanded getting-started checklist and one
primary action towards the import page, instead of widgets showing 0 $.
With transactions, the compact checklist sits between the title and the
filter panel until it is complete or dismissed.

- `EmptyState` (pure, dashed card from the import source list; a `to`
  action renders a react-router `<Link>`, an `onClick` action a button)
- `OnboardingChecklist` (pure, expanded and compact modes; undone rows
  link to their route, the locked Budget row shows a lock, the "Base and
  up" label and links to the license card; neutral budget row and hidden
  counter until the entitlement is known)
- `DashboardEmptyState` composes both
- `DashboardPage` branches on the hook `status`: loading renders the title,
  help and banner only; error falls back to the v0.15.0 page without the
  list and logs a warning; ready with zero transactions renders the empty
  state outside the `isLoading` veil
- `useOnboardingChecklist` exposes `counters` and `error` for the page
- Users settings: "Show the getting-started checklist" button in the Guide
  section, visible only when the preference is set, with an inline
  confirmation after the click
- FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`,
  `settings.users.onboarding.*`

Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct
call (FilterPanel pattern). No DB migration. CHANGELOG left to #357.

Resolves #352

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:32:43 -04:00

149 lines
4.5 KiB
TypeScript

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