feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352) #378

Merged
maximus merged 2 commits from issue-352-dashboard-empty-state into main 2026-10-04 12:51:19 +00:00
10 changed files with 764 additions and 4 deletions

View file

@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { LayoutDashboard } from "lucide-react";
import EmptyState from "../shared/EmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
import type { Checklist } from "../../utils/onboardingChecklist";
interface DashboardEmptyStateProps {
checklist: Checklist;
budgetReady: boolean;
/** The user dismissed the checklist: the welcome card stays, the list goes. */
hidden: boolean;
onHide: () => void;
}
/**
* Dashboard of a profile without any transaction (#352): a welcome card with
* the expanded getting-started checklist and one primary action towards the
* import page, in place of widgets showing 0 $.
*/
export default function DashboardEmptyState({
checklist,
budgetReady,
hidden,
onHide,
}: DashboardEmptyStateProps) {
const { t } = useTranslation();
return (
<EmptyState
icon={<LayoutDashboard size={40} />}
title={t("dashboard.empty.title")}
description={t("dashboard.empty.description")}
primaryAction={{ label: t("dashboard.empty.cta"), to: "/import" }}
>
{!hidden && (
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
mode="expanded"
budgetReady={budgetReady}
onHide={onHide}
/>
)}
</EmptyState>
);
}

View file

@ -0,0 +1,183 @@
// OnboardingChecklist — unit tests (#352).
//
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
// mocked, the component called as a function and its element tree walked.
// `t` returns the key, with the interpolation values appended, so the asserts
// read the i18n keys and never a hard-coded string.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) =>
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
i18n: { language: "fr" },
}),
}));
vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
Circle: () => null,
Lock: () => null,
}));
import { CheckCircle2, Circle, Lock } from "lucide-react";
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import type { OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock]);
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
function expand(node: ReactNode): ReactNode {
if (Array.isArray(node)) return node.map(expand);
if (!isValidElement(node)) return node;
const el = node as AnyElement;
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
const render = el.type as (props: Record<string, unknown>) => ReactNode;
return expand(render(el.props));
}
const children = el.props.children;
return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode;
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
function render(overrides: Partial<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
const onHide = vi.fn();
const props: OnboardingChecklistProps = {
items: checklist.items,
done: checklist.done,
total: checklist.total,
mode: "expanded",
budgetReady: true,
onHide,
...overrides,
};
return { element: expand(OnboardingChecklist(props)), onHide, checklist };
}
function rowOf(element: ReactNode, key: string): AnyElement {
const row = find(element, (e) => e.type === "li" && e.key === key)[0];
expect(row, `row ${key}`).toBeDefined();
return row;
}
describe("OnboardingChecklist — expanded", () => {
it("renders the title, the counter 1 / 6 and the hide button", () => {
const { element, onHide } = render();
const text = texts(element);
expect(text).toContain("onboarding.checklist.title");
expect(text).toContain("onboarding.checklist.counter[done=1,total=6]");
const [hide] = find(element, (e) => e.type === "button");
expect(hide.props.type).toBe("button");
expect(texts(hide.props.children)).toContain("onboarding.checklist.hide");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
});
it("renders six rows with their i18n labels", () => {
const { element } = render();
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
const text = texts(element);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
expect(text).toContain(`onboarding.checklist.items.${key}`);
}
});
it("links every undone row to its `to`, and never a done row", () => {
const { element, checklist } = render();
const links = find(element, (e) => e.type === Link);
const undone = checklist.items.filter((i) => !i.done);
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
const profile = rowOf(element, "profile");
expect(find(profile, (e) => e.type === Link)).toHaveLength(0);
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
});
it("marks a row done when its counter is reached", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
});
it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => {
const { element } = render({}, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
});
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(0);
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
expect(texts(budget)).not.toContain("onboarding.checklist.lockedBudget");
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
});
});
describe("OnboardingChecklist — compact", () => {
it("renders the icons on one line with the counter, without the row sentences", () => {
const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 });
const text = texts(element);
expect(text).toContain("onboarding.checklist.title");
expect(text).toContain("onboarding.checklist.counter[done=3,total=6]");
expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false);
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3);
expect(find(element, (e) => e.type === Circle)).toHaveLength(3);
});
it("labels each icon for assistive technologies and links the undone rows", () => {
const { element, checklist } = render({ mode: "compact" });
const links = find(element, (e) => e.type === Link);
const undone = checklist.items.filter((i) => !i.done);
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
for (const link of links) {
expect(String(link.props["aria-label"])).toMatch(/^onboarding\.checklist\.items\./);
}
const [profileIcon] = find(element, (e) => e.type === "span" && e.props.role === "img");
expect(profileIcon.props["aria-label"]).toBe("onboarding.checklist.items.profile");
});
it("keeps the hide button and hides the counter until the entitlement is known", () => {
const { element, onHide } = render({ mode: "compact", budgetReady: false });
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
const [hide] = find(element, (e) => e.type === "button");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,164 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CheckCircle2, Circle, Lock } from "lucide-react";
import type { ChecklistItem } from "../../shared/types";
export type OnboardingChecklistMode = "expanded" | "compact";
export interface OnboardingChecklistProps {
items: ChecklistItem[];
/** Number of unlocked rows that are done. */
done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */
total: number;
/** `expanded` = one row per line with its label; `compact` = icons on one line. */
mode: OnboardingChecklistMode;
/**
* False until the Budget entitlement is known. The budget row is then
* neutral (neither done nor locked) and the counter is hidden, because
* `total` depends on the lock.
*/
budgetReady: boolean;
onHide: () => void;
}
type RowState = "done" | "todo" | "locked" | "pending";
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
if (item.key === "budget" && !budgetReady) return "pending";
if (item.locked) return "locked";
return item.done ? "done" : "todo";
}
const ICON_CLASS: Record<RowState, string> = {
done: "text-[var(--positive)]",
todo: "text-[var(--muted-foreground)]",
locked: "text-[var(--muted-foreground)]",
pending: "text-[var(--muted-foreground)] opacity-50",
};
function RowIcon({ state, size }: { state: RowState; size: number }) {
const className = `shrink-0 ${ICON_CLASS[state]}`;
if (state === "done") return <CheckCircle2 size={size} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
return <Circle size={size} className={className} aria-hidden="true" />;
}
/**
* Getting-started checklist of the dashboard (#352). Pure: the only hook is
* `useTranslation`, so the component is tested by calling it as a function.
*
* - A row that is not done links to its `to` route; a done row has no link.
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
* label and links to the license card (`/settings/users`).
* - Compact mode keeps the title, the icons (one per row, labelled for
* assistive technologies), the counter and the hide button, no sentences.
*/
export default function OnboardingChecklist({
items,
done,
total,
mode,
budgetReady,
onHide,
}: OnboardingChecklistProps) {
const { t } = useTranslation();
const counter = budgetReady ? (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
) : null;
const hideButton = (
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
);
if (mode === "compact") {
return (
<div
role="region"
aria-label={t("onboarding.checklist.title")}
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2 mb-6"
>
<span className="text-sm font-semibold">{t("onboarding.checklist.title")}</span>
<ul className="flex items-center gap-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
const icon = <RowIcon state={state} size={18} />;
return (
<li key={item.key} className="flex">
{state === "todo" || state === "locked" ? (
<Link
to={item.to}
title={label}
aria-label={label}
className="rounded-md p-0.5 hover:bg-[var(--muted)] transition-colors"
>
{icon}
</Link>
) : (
<span title={label} aria-label={label} role="img" className="p-0.5">
{icon}
</span>
)}
</li>
);
})}
</ul>
{counter}
<span className="ml-auto">{hideButton}</span>
</div>
);
}
return (
<div
role="region"
aria-label={t("onboarding.checklist.title")}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4"
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{counter}
<span className="ml-auto">{hideButton}</span>
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
return (
<li key={item.key} className="flex items-center gap-3 text-sm">
<RowIcon state={state} size={20} />
{state === "todo" && (
<Link to={item.to} className="hover:underline text-[var(--foreground)]">
{label}
</Link>
)}
{state === "locked" && (
<Link
to={item.to}
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
>
<span>{label}</span>
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
{t("onboarding.checklist.lockedBudget")}
</span>
</Link>
)}
{state === "done" && (
<span className="text-[var(--muted-foreground)] line-through">{label}</span>
)}
{state === "pending" && <span className="text-[var(--muted-foreground)]">{label}</span>}
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,111 @@
// EmptyState — unit tests (#352).
//
// No jsdom / testing-library in this project (see FilterPanel.test.tsx): the
// component is pure (no hook), so it is called as a plain function and its
// React-element tree is walked directly. `Link` is react-router's real
// component: creating the element never needs a router, only rendering would.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
import EmptyState from "./EmptyState";
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as ReactElement<{ children?: ReactNode }>;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as ReactElement<{ children?: ReactNode }>).props.children);
}
const ICON = "ICON_MARKER";
describe("EmptyState", () => {
it("renders the icon, the title and the description", () => {
const el = EmptyState({ icon: ICON, title: "Title", description: "Desc" });
const text = texts(el);
expect(text).toContain(ICON);
expect(text).toContain("Title");
expect(text).toContain("Desc");
});
it("renders no action container when no action is given", () => {
const el = EmptyState({ icon: ICON, title: "Title" });
expect(find(el, (e) => e.type === Link)).toHaveLength(0);
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
});
it("renders a `to` action as a react-router Link, never a button", () => {
const el = EmptyState({
icon: ICON,
title: "Title",
primaryAction: { label: "Go", to: "/import" },
});
const links = find(el, (e) => e.type === Link) as ReactElement<{ to: string; children: ReactNode }>[];
expect(links).toHaveLength(1);
expect(links[0].props.to).toBe("/import");
expect(texts(links[0].props.children)).toContain("Go");
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
});
it("renders an `onClick` action as a type=button that calls the handler", () => {
const onClick = vi.fn();
const el = EmptyState({
icon: ICON,
title: "Title",
primaryAction: { label: "Do", onClick },
});
const buttons = find(el, (e) => e.type === "button") as ReactElement<{
type: string;
disabled: boolean;
onClick: () => void;
}>[];
expect(buttons).toHaveLength(1);
expect(buttons[0].props.type).toBe("button");
expect(buttons[0].props.disabled).toBe(false);
buttons[0].props.onClick();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("propagates `disabled` on the button variant", () => {
const el = EmptyState({
icon: ICON,
title: "Title",
primaryAction: { label: "Do", onClick: vi.fn(), disabled: true },
});
const [button] = find(el, (e) => e.type === "button") as ReactElement<{ disabled: boolean }>[];
expect(button.props.disabled).toBe(true);
});
it("renders both actions, primary first", () => {
const el = EmptyState({
icon: ICON,
title: "Title",
primaryAction: { label: "Primary", to: "/a" },
secondaryAction: { label: "Secondary", onClick: vi.fn() },
});
const actions = find(el, (e) => e.type === Link || e.type === "button");
expect(actions).toHaveLength(2);
expect(actions[0].type).toBe(Link);
expect(actions[1].type).toBe("button");
});
it("renders children between the description and the actions", () => {
const el = EmptyState({
icon: ICON,
title: "Title",
description: "Desc",
primaryAction: { label: "Go", to: "/a" },
children: "CHILD_MARKER",
});
const text = texts(el);
expect(text.indexOf("Desc")).toBeLessThan(text.indexOf("CHILD_MARKER"));
expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go"));
});
});

View file

@ -0,0 +1,80 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
/**
* An action of the empty state: either a route (rendered as a react-router
* `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`).
*/
export type EmptyStateAction =
| { label: string; to: string }
| { label: string; onClick: () => void; disabled?: boolean };
export interface EmptyStateProps {
/** Centered icon, already sized by the caller. */
icon: ReactNode;
/** Translated title. */
title: string;
/** Translated sentence under the title. */
description?: string;
primaryAction?: EmptyStateAction;
secondaryAction?: EmptyStateAction;
/** Rendered between the description and the actions. */
children?: ReactNode;
}
const PRIMARY_CLASS =
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed";
const SECONDARY_CLASS =
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
function renderAction(action: EmptyStateAction, className: string) {
if ("to" in action) {
return (
<Link to={action.to} className={className}>
{action.label}
</Link>
);
}
return (
<button
type="button"
onClick={action.onClick}
disabled={action.disabled ?? false}
className={className}
>
{action.label}
</button>
);
}
/**
* Pure empty-state card (#352): dashed border and centered icon, the same
* styling as the empty source list of the import page. No hook inside, so the
* component is tested by calling it as a function (FilterPanel.test.tsx pattern).
* Strings are passed already translated by the caller.
*/
export default function EmptyState({
icon,
title,
description,
primaryAction,
secondaryAction,
children,
}: EmptyStateProps) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 md:p-12 border-2 border-dashed border-[var(--border)] text-center">
<div className="mx-auto mb-4 flex justify-center text-[var(--muted-foreground)]">{icon}</div>
<h2 className="text-xl font-semibold text-[var(--foreground)]">{title}</h2>
{description && (
<p className="mt-2 text-[var(--muted-foreground)] max-w-xl mx-auto">{description}</p>
)}
{children && <div className="mt-6 text-left max-w-xl mx-auto">{children}</div>}
{(primaryAction || secondaryAction) && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
{primaryAction && renderAction(primaryAction, PRIMARY_CLASS)}
{secondaryAction && renderAction(secondaryAction, SECONDARY_CLASS)}
</div>
)}
</div>
);
}

View file

@ -136,7 +136,11 @@ export function useOnboardingChecklist() {
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,

View file

@ -57,6 +57,27 @@
"Rising/falling categories and budget adherence summarize the reference month's changes",
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
]
},
"empty": {
"title": "Welcome to Simpl'Résultat",
"description": "After your first import, this page will show your income, expenses, savings rate and top categories for the selected month.",
"cta": "Choose an import folder"
}
},
"onboarding": {
"checklist": {
"title": "Getting started",
"counter": "{{done}} / {{total}}",
"hide": "Hide",
"lockedBudget": "Base and up",
"items": {
"profile": "Create a profile",
"importFolder": "Choose an import folder",
"source": "Set up an import source",
"import": "Import a first file",
"manualCategory": "Categorize a transaction by hand",
"budget": "Enter a first budget"
}
}
},
"import": {
@ -722,6 +743,11 @@
"accounts": "Accounts",
"licenses": "Licenses",
"userGuide": "User guide"
},
"onboarding": {
"label": "Getting-started checklist on the dashboard",
"show": "Show the getting-started checklist",
"confirmation": "The checklist will reappear on the dashboard"
}
},
"data": {

View file

@ -57,6 +57,27 @@
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
]
},
"empty": {
"title": "Bienvenue dans Simpl'Résultat",
"description": "Après votre premier import, cette page montrera vos revenus, vos dépenses, votre taux d'épargne et vos principales catégories pour le mois choisi.",
"cta": "Choisir un dossier d'import"
}
},
"onboarding": {
"checklist": {
"title": "Liste de démarrage",
"counter": "{{done}} / {{total}}",
"hide": "Masquer",
"lockedBudget": "Base et plus",
"items": {
"profile": "Créer un profil",
"importFolder": "Choisir un dossier d'import",
"source": "Configurer une source d'import",
"import": "Importer un premier fichier",
"manualCategory": "Classer une transaction à la main",
"budget": "Saisir un premier budget"
}
}
},
"import": {
@ -722,6 +743,11 @@
"accounts": "Comptes",
"licenses": "Licences",
"userGuide": "Guide d'utilisation"
},
"onboarding": {
"label": "Liste de démarrage sur le tableau de bord",
"show": "Afficher la liste de démarrage",
"confirmation": "La liste réapparaîtra sur le tableau de bord"
}
},
"data": {

View file

@ -7,13 +7,26 @@
// ranked bar chart (replacing the former expense-only pie) and the net-worth
// tile (from the Bilan — a different metric from every transactional widget
// here, see NetWorthTile's own header comment).
import { useState, useCallback, useMemo } from "react";
//
// Onboarding (#352): the page branches on the getting-started checklist
// `status`, never on a bare counter. While it loads, only the title and the
// banner are rendered (no flash of 0 $ widgets nor of the empty state). A
// profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With
// transactions, the compact checklist sits between the title and the filter
// panel until it is complete or dismissed. A load error falls back to the
// v0.15.0 page without the list.
import { useState, useCallback, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useDashboard } from "../hooks/useDashboard";
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
import { logWarn } from "../services/logService";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
import OnboardingChecklist from "../components/dashboard/OnboardingChecklist";
import NetWorthTile from "../components/dashboard/NetWorthTile";
import FilterPanel from "../components/reports/FilterPanel";
import KpiCard from "../components/reports/cards/KpiCard";
@ -35,6 +48,13 @@ export default function DashboardPage() {
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const onboarding = useOnboardingChecklist();
useEffect(() => {
if (onboarding.status === "error") {
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
}
}, [onboarding.status, onboarding.error]);
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -58,13 +78,53 @@ export default function DashboardPage() {
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
const header = (
<>
<CategoriesV1DiscoveryBanner />
<div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" />
</div>
</>
);
if (onboarding.status === "loading") {
return <div>{header}</div>;
}
const { checklist } = onboarding;
const checklistReady = onboarding.status === "ready" && checklist !== null;
if (checklistReady && onboarding.counters?.transactions === 0) {
return (
<div>
{header}
<DashboardEmptyState
checklist={checklist}
budgetReady={onboarding.budgetReady}
hidden={onboarding.hidden}
onHide={onboarding.hide}
/>
</div>
);
}
const showCompactChecklist = checklistReady && !onboarding.hidden && !checklist.complete;
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
{header}
{showCompactChecklist && (
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
mode="compact"
budgetReady={onboarding.budgetReady}
onHide={onboarding.hide}
/>
)}
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">

View file

@ -1,12 +1,52 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, ListChecks } from "lucide-react";
import AccountCard from "../../components/settings/AccountCard";
import LicenseCard from "../../components/settings/LicenseCard";
import DocsContent from "../../components/settings/DocsContent";
import { getPreference } from "../../services/userPreferenceService";
import {
ONBOARDING_CHECKLIST_HIDDEN_KEY,
showChecklist,
} from "../../services/onboardingService";
import { logError } from "../../services/logService";
// Getting-started checklist preference, for its re-show button (#352).
// `dismissed` = the preference was "1" at mount, the button is offered;
// `restored` = the button was clicked and the preference removed, so a
// confirmation line replaces it; `shown` = nothing to offer.
type ChecklistPreference = "unknown" | "shown" | "dismissed" | "restored";
export default function UsersSettingsPage() {
const { t } = useTranslation();
const [checklistPref, setChecklistPref] = useState<ChecklistPreference>("unknown");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const value = await getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
if (!cancelled) setChecklistPref(value === "1" ? "dismissed" : "shown");
} catch (e) {
// Informational control: without the preference, keep it out of the way.
if (!cancelled) setChecklistPref("shown");
logError("Settings: failed to read the checklist preference", e);
}
})();
return () => {
cancelled = true;
};
}, []);
const handleShowChecklist = async () => {
try {
await showChecklist();
setChecklistPref("restored");
} catch (e) {
logError("Settings: failed to show the checklist", e);
}
};
return (
<div className="space-y-6">
@ -38,6 +78,25 @@ export default function UsersSettingsPage() {
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.userGuide")}
</h2>
{(checklistPref === "dismissed" || checklistPref === "restored") && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-sm">
<ListChecks size={18} className="shrink-0 text-[var(--muted-foreground)]" aria-hidden="true" />
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
{checklistPref === "dismissed" ? (
<button
type="button"
onClick={handleShowChecklist}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-[var(--border)] font-medium hover:bg-[var(--muted)] transition-colors"
>
{t("settings.users.onboarding.show")}
</button>
) : (
<span role="status" className="text-[var(--muted-foreground)]">
{t("settings.users.onboarding.confirmation")}
</span>
)}
</div>
)}
<DocsContent />
</section>
</div>