All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
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>
149 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|